From 529f8cecc241637bffac2b0244f29c40fcdcb384 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Tue, 16 Jun 2026 17:05:42 +0530 Subject: [PATCH 01/27] update the apiproject init command logic --- cli/src/cmd/apiproject/init.go | 147 ++++++++++++++++------------ cli/src/cmd/apiproject/init_test.go | 2 +- 2 files changed, 86 insertions(+), 63 deletions(-) diff --git a/cli/src/cmd/apiproject/init.go b/cli/src/cmd/apiproject/init.go index d713219578..6448580a65 100644 --- a/cli/src/cmd/apiproject/init.go +++ b/cli/src/cmd/apiproject/init.go @@ -31,16 +31,14 @@ import ( const ( InitCmdLiteral = "init" InitCmdExample = `# Initialize a new API project -ap apiproject init --display-name foo-api --type rest --version 1.0 --context /foo +ap apiproject init --display-name foo-api --type rest # Add a API project fully interactively cobra ap apiproject init` ) var displayName string -var apiType string -var apiVersion string -var apiContext string +var projectType string var addNoInteractive bool var initCmd = &cobra.Command{ @@ -57,10 +55,8 @@ var initCmd = &cobra.Command{ } func init() { - utils.AddStringFlag(initCmd, utils.FlagName, &displayName, "", "Display name of the API") - utils.AddStringFlag(initCmd, utils.FlagType, &apiType, "", "Type of the API") - utils.AddStringFlag(initCmd, utils.FlagVersion, &apiVersion, "", "Version of the API") - utils.AddStringFlag(initCmd, utils.FlagContext, &apiContext, "", "Context of the API") + utils.AddStringFlag(initCmd, utils.FlagName, &displayName, "", "Display name of the artifact") + utils.AddStringFlag(initCmd, utils.FlagType, &projectType, "", "Type of the artifact") utils.AddBoolFlag(initCmd, utils.FlagNoInteractive, &addNoInteractive, false, "Skip interactive prompts") } @@ -73,59 +69,39 @@ func runInitCommand() error { return fmt.Errorf("Failed to read display name: %w", err) } } - if strings.TrimSpace(apiType) == "" { - apiType, err = utils.PromptInput("Enter API type (e.g., rest): ") + if strings.TrimSpace(projectType) == "" { + projectType, err = utils.PromptInput(fmt.Sprintf("Enter artifact type (%s): ", strings.Join(supportedArtifactTypes(), ", "))) if err != nil { - return fmt.Errorf("Failed to read API type: %w", err) - } - } - if strings.TrimSpace(apiVersion) == "" { - apiVersion, err = utils.PromptInput("Enter API version: ") - if err != nil { - return fmt.Errorf("Failed to read API version: %w", err) - } - } - if strings.TrimSpace(apiContext) == "" { - apiContext, err = utils.PromptInput("Enter API context (e.g., /foo): ") - if err != nil { - return fmt.Errorf("Failed to read API context: %w", err) + return fmt.Errorf("Failed to read artifact type: %w", err) } } } displayName = strings.TrimSpace(displayName) - apiType = strings.ToLower(strings.TrimSpace(apiType)) - apiVersion = strings.TrimSpace(apiVersion) - apiContext = strings.TrimSpace(apiContext) + projectType = strings.ToLower(strings.TrimSpace(projectType)) if displayName == "" { return fmt.Errorf("display name is required") } - if apiType == "" { - return fmt.Errorf("API type is required") - } - if apiVersion == "" { - return fmt.Errorf("API version is required") - } - if apiContext == "" { - return fmt.Errorf("API context is required") + if projectType == "" { + return fmt.Errorf("artifact type is required") } - if apiType != utils.APITypeREST { - return fmt.Errorf("unsupported API type: %s", apiType) + if !isValidArtifactType(projectType) { + return fmt.Errorf("unsupported artifact type: %s (supported types: %s)", projectType, strings.Join(supportedArtifactTypes(), ", ")) } - if err := buildDirectoryStructure(displayName, apiType, apiVersion, apiContext); err != nil { + if err := buildDirectoryStructure(displayName, projectType); err != nil { return err } - fmt.Printf("API project created at .%c%s\n", os.PathSeparator, displayName) + fmt.Printf("Artifact project created at .%c%s\n", os.PathSeparator, displayName) return nil } -func buildDirectoryStructure(name, apiType, version, context string) error { - if apiType != utils.APITypeREST { - return fmt.Errorf("API project scaffolding currently supports only %s APIs", utils.APITypeREST) +func buildDirectoryStructure(name, artifactType string) error { + if !isValidArtifactType(artifactType) { + return fmt.Errorf("unsupported artifact type: %s (supported types: %s)", artifactType, strings.Join(supportedArtifactTypes(), ", ")) } projectDirName, err := validateProjectDirectoryName(name) @@ -156,12 +132,12 @@ func buildDirectoryStructure(name, apiType, version, context string) error { } } - resourceName := buildResourceName(name, version) + resourceName := buildResourceName(name) files := map[string]string{ filepath.Join(projectRoot, ".api-platform", "config.yaml"): buildConfigYAML(), - filepath.Join(projectRoot, "api.yaml"): buildAPIYAML(resourceName), - filepath.Join(projectRoot, "gateway.yaml"): buildGatewayYAML(resourceName, name, version, context), - filepath.Join(projectRoot, "definition.yaml"): buildDefinitionYAML(name, version, context), + filepath.Join(projectRoot, "metadata.yaml"): buildMetadataYAML(resourceName, artifactType), + filepath.Join(projectRoot, "runtime.yaml"): buildRuntimeYAML(resourceName, name, artifactType), + filepath.Join(projectRoot, "definition.yaml"): buildDefinitionYAML(name), } for path, content := range files { if err := os.WriteFile(path, []byte(content), 0644); err != nil { @@ -193,7 +169,7 @@ func validateProjectDirectoryName(name string) (string, error) { return name, nil } -func buildResourceName(name, version string) string { +func buildResourceName(name string) string { normalized := strings.ToLower(strings.TrimSpace(name)) normalized = strings.ReplaceAll(normalized, "_", "-") normalized = strings.ReplaceAll(normalized, " ", "-") @@ -209,7 +185,7 @@ func buildResourceName(name, version string) string { normalized = "api" } - return fmt.Sprintf("%s-%s", normalized, version) + return fmt.Sprintf("%s", normalized) } func buildConfigYAML() string { @@ -217,8 +193,8 @@ func buildConfigYAML() string { # Default file paths (can be customized) filePaths: - deploymentArtifact: ./gateway.yaml - apiMetadata: ./api.yaml + deploymentArtifact: ./runtime.yaml + metadataFile: ./metadata.yaml apiDefinition: ./definition.yaml docs: ./docs tests: ./tests @@ -232,9 +208,11 @@ autoSync: ` } -func buildAPIYAML(resourceName string) string { - return fmt.Sprintf(`apiVersion: management.api-platform.wso2.com/v1 -kind: Api +func buildMetadataYAML(resourceName, artifactType string) string { + apiVersion := getApiVersion(artifactType) + kind := getArtifactKind(artifactType) + return fmt.Sprintf(`apiVersion: %s +kind: %s metadata: name: %q spec: @@ -252,18 +230,19 @@ spec: endpoints: sandboxUrl: "" productionUrl: "" -`, resourceName) +`, apiVersion, kind, resourceName) } -func buildGatewayYAML(resourceName, displayName, version, context string) string { +func buildRuntimeYAML(resourceName, displayName, artifactType string) string { + kind := getArtifactKind(artifactType) return fmt.Sprintf(`apiVersion: gateway.api-platform.wso2.com/v1 -kind: RestApi +kind: %s metadata: name: %q spec: displayName: %q - version: %q - context: %q + version: + context: upstream: main: url: "http://sample-backend.org:9080" # Change this to your backend URL @@ -278,16 +257,16 @@ spec: method: DELETE - path: /* method: OPTIONS -`, resourceName, displayName, version, context) +`, kind, resourceName, displayName) } -func buildDefinitionYAML(displayName, version, context string) string { +func buildDefinitionYAML(displayName string) string { return fmt.Sprintf(`openapi: 3.0.3 info: title: %q - version: %q + version: v1.0 servers: - - url: %q + - url: https://example.com paths: "/*": get: @@ -310,5 +289,49 @@ paths: responses: "200": description: OK -`, displayName, version, context) +`, displayName) +} + +func supportedArtifactTypes() []string { + return []string{ + utils.TypeREST, + utils.TypeLLMProxy, + utils.TypeLLMProvider, + utils.TypeLLMProviderTemplate, + utils.TypeMCPProxy, + } } + +func isValidArtifactType(artifactType string) bool { + for _, t := range supportedArtifactTypes() { + if artifactType == t { + return true + } + } + return false +} + +func getArtifactKind(artifactType string) string { + kindMap := map[string]string{ + utils.TypeREST: "RestApi", + utils.TypeLLMProxy: "LlmProxy", + utils.TypeLLMProvider: "LlmProvider", + utils.TypeLLMProviderTemplate: "LlmProviderTemplate", + utils.TypeMCPProxy: "McpProxy", + } + if kind, exists := kindMap[artifactType]; exists { + return kind + } + return "RestApi" +} + +func getApiVersion(artifactType string) string { + switch artifactType { + case utils.TypeREST: + return "management.api-platform.wso2.com/v1" + case utils.TypeLLMProxy, utils.TypeLLMProvider, utils.TypeLLMProviderTemplate, utils.TypeMCPProxy: + return "ai-workspace.api-platform.wso2.com/v1" + default: + return "management.api-platform.wso2.com/v1" + } +} \ No newline at end of file diff --git a/cli/src/cmd/apiproject/init_test.go b/cli/src/cmd/apiproject/init_test.go index 8991b0baf1..1a55a6981b 100644 --- a/cli/src/cmd/apiproject/init_test.go +++ b/cli/src/cmd/apiproject/init_test.go @@ -43,7 +43,7 @@ func TestBuildDirectoryStructureCreatesExpectedFiles(t *testing.T) { t.Fatalf("failed to change working directory: %v", err) } - if err := buildDirectoryStructure("FooAPI", utils.APITypeREST, "1.0.0", "/petstore"); err != nil { + if err := buildDirectoryStructure("FooAPI", utils.TypeREST); err != nil { t.Fatalf("buildDirectoryStructure returned an error: %v", err) } From 304e8ee35a6ffa408b56ee2b1b5604be60eca106 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Wed, 17 Jun 2026 08:49:04 +0530 Subject: [PATCH 02/27] update project init, devportal build commands with correct expected behaviour --- cli/src/cmd/apiproject/init.go | 183 ++---------- cli/src/cmd/apiproject/init_test.go | 101 ++++++- cli/src/cmd/devportal/build.go | 417 ++++++++++++++++----------- cli/src/cmd/devportal/build_test.go | 228 ++++++++++----- cli/src/internal/project/artifact.go | 95 ++++++ cli/src/internal/project/config.go | 148 ++++++++++ cli/src/internal/project/scaffold.go | 300 +++++++++++++++++++ cli/src/utils/constants.go | 11 +- cli/src/utils/flags.go | 2 + 9 files changed, 1070 insertions(+), 415 deletions(-) create mode 100644 cli/src/internal/project/artifact.go create mode 100644 cli/src/internal/project/config.go create mode 100644 cli/src/internal/project/scaffold.go diff --git a/cli/src/cmd/apiproject/init.go b/cli/src/cmd/apiproject/init.go index 6448580a65..79aa682102 100644 --- a/cli/src/cmd/apiproject/init.go +++ b/cli/src/cmd/apiproject/init.go @@ -25,6 +25,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/project" "github.com/wso2/api-platform/cli/utils" ) @@ -70,7 +71,7 @@ func runInitCommand() error { } } if strings.TrimSpace(projectType) == "" { - projectType, err = utils.PromptInput(fmt.Sprintf("Enter artifact type (%s): ", strings.Join(supportedArtifactTypes(), ", "))) + projectType, err = utils.PromptInput(fmt.Sprintf("Enter artifact type (%s): ", strings.Join(project.SupportedArtifactTypes(), ", "))) if err != nil { return fmt.Errorf("Failed to read artifact type: %w", err) } @@ -87,8 +88,8 @@ func runInitCommand() error { return fmt.Errorf("artifact type is required") } - if !isValidArtifactType(projectType) { - return fmt.Errorf("unsupported artifact type: %s (supported types: %s)", projectType, strings.Join(supportedArtifactTypes(), ", ")) + if !project.IsValidArtifactType(projectType) { + return fmt.Errorf("unsupported artifact type: %s (supported types: %s)", projectType, strings.Join(project.SupportedArtifactTypes(), ", ")) } if err := buildDirectoryStructure(displayName, projectType); err != nil { @@ -100,8 +101,8 @@ func runInitCommand() error { } func buildDirectoryStructure(name, artifactType string) error { - if !isValidArtifactType(artifactType) { - return fmt.Errorf("unsupported artifact type: %s (supported types: %s)", artifactType, strings.Join(supportedArtifactTypes(), ", ")) + if !project.IsValidArtifactType(artifactType) { + return fmt.Errorf("unsupported artifact type: %s (supported types: %s)", artifactType, strings.Join(project.SupportedArtifactTypes(), ", ")) } projectDirName, err := validateProjectDirectoryName(name) @@ -133,11 +134,25 @@ func buildDirectoryStructure(name, artifactType string) error { } resourceName := buildResourceName(name) + + configYAML, err := project.BuildConfigYAML() + if err != nil { + return err + } + metadataYAML, err := project.BuildMetadataYAML(artifactType, resourceName, name) + if err != nil { + return err + } + runtimeYAML, err := project.BuildRuntimeYAML(artifactType, resourceName, name) + if err != nil { + return err + } + files := map[string]string{ - filepath.Join(projectRoot, ".api-platform", "config.yaml"): buildConfigYAML(), - filepath.Join(projectRoot, "metadata.yaml"): buildMetadataYAML(resourceName, artifactType), - filepath.Join(projectRoot, "runtime.yaml"): buildRuntimeYAML(resourceName, name, artifactType), - filepath.Join(projectRoot, "definition.yaml"): buildDefinitionYAML(name), + filepath.Join(projectRoot, ".api-platform", "config.yaml"): configYAML, + filepath.Join(projectRoot, "metadata.yaml"): metadataYAML, + filepath.Join(projectRoot, "runtime.yaml"): runtimeYAML, + filepath.Join(projectRoot, "definition.yaml"): project.BuildDefinitionYAML(name), } for path, content := range files { if err := os.WriteFile(path, []byte(content), 0644); err != nil { @@ -185,153 +200,5 @@ func buildResourceName(name string) string { normalized = "api" } - return fmt.Sprintf("%s", normalized) -} - -func buildConfigYAML() string { - return `version: 1.0.0 - -# Default file paths (can be customized) -filePaths: - deploymentArtifact: ./runtime.yaml - metadataFile: ./metadata.yaml - apiDefinition: ./definition.yaml - docs: ./docs - tests: ./tests - -# Governance rulesets for design-time validation -governanceRulesets: [] - -# Auto-sync configuration for vscode plugin -autoSync: - gatewayArtifactFromDefinition: true # Auto-generate gateway.yaml when definition.yaml changes -` -} - -func buildMetadataYAML(resourceName, artifactType string) string { - apiVersion := getApiVersion(artifactType) - kind := getArtifactKind(artifactType) - return fmt.Sprintf(`apiVersion: %s -kind: %s -metadata: - name: %q -spec: - description: "" - gatewayType: wso2/api-platform - status: PUBLISHED - referenceID: "" - tags: [] - labels: [] - businessInformation: - businessOwner: "" - businessOwnerEmail: "" - technicalOwner: "" - technicalOwnerEmail: "" - endpoints: - sandboxUrl: "" - productionUrl: "" -`, apiVersion, kind, resourceName) + return normalized } - -func buildRuntimeYAML(resourceName, displayName, artifactType string) string { - kind := getArtifactKind(artifactType) - return fmt.Sprintf(`apiVersion: gateway.api-platform.wso2.com/v1 -kind: %s -metadata: - name: %q -spec: - displayName: %q - version: - context: - upstream: - main: - url: "http://sample-backend.org:9080" # Change this to your backend URL - operations: - - path: /* - method: GET - - path: /* - method: POST - - path: /* - method: PUT - - path: /* - method: DELETE - - path: /* - method: OPTIONS -`, kind, resourceName, displayName) -} - -func buildDefinitionYAML(displayName string) string { - return fmt.Sprintf(`openapi: 3.0.3 -info: - title: %q - version: v1.0 -servers: - - url: https://example.com -paths: - "/*": - get: - responses: - "200": - description: OK - post: - responses: - "200": - description: OK - put: - responses: - "200": - description: OK - delete: - responses: - "200": - description: OK - options: - responses: - "200": - description: OK -`, displayName) -} - -func supportedArtifactTypes() []string { - return []string{ - utils.TypeREST, - utils.TypeLLMProxy, - utils.TypeLLMProvider, - utils.TypeLLMProviderTemplate, - utils.TypeMCPProxy, - } -} - -func isValidArtifactType(artifactType string) bool { - for _, t := range supportedArtifactTypes() { - if artifactType == t { - return true - } - } - return false -} - -func getArtifactKind(artifactType string) string { - kindMap := map[string]string{ - utils.TypeREST: "RestApi", - utils.TypeLLMProxy: "LlmProxy", - utils.TypeLLMProvider: "LlmProvider", - utils.TypeLLMProviderTemplate: "LlmProviderTemplate", - utils.TypeMCPProxy: "McpProxy", - } - if kind, exists := kindMap[artifactType]; exists { - return kind - } - return "RestApi" -} - -func getApiVersion(artifactType string) string { - switch artifactType { - case utils.TypeREST: - return "management.api-platform.wso2.com/v1" - case utils.TypeLLMProxy, utils.TypeLLMProvider, utils.TypeLLMProviderTemplate, utils.TypeMCPProxy: - return "ai-workspace.api-platform.wso2.com/v1" - default: - return "management.api-platform.wso2.com/v1" - } -} \ No newline at end of file diff --git a/cli/src/cmd/apiproject/init_test.go b/cli/src/cmd/apiproject/init_test.go index 1a55a6981b..c4e0298779 100644 --- a/cli/src/cmd/apiproject/init_test.go +++ b/cli/src/cmd/apiproject/init_test.go @@ -26,7 +26,8 @@ import ( "github.com/wso2/api-platform/cli/utils" ) -func TestBuildDirectoryStructureCreatesExpectedFiles(t *testing.T) { +func chdirTemp(t *testing.T) string { + t.Helper() tempDir := t.TempDir() originalWD, err := os.Getwd() @@ -42,6 +43,11 @@ func TestBuildDirectoryStructureCreatesExpectedFiles(t *testing.T) { if err := os.Chdir(tempDir); err != nil { t.Fatalf("failed to change working directory: %v", err) } + return tempDir +} + +func TestBuildDirectoryStructureCreatesExpectedFiles(t *testing.T) { + tempDir := chdirTemp(t) if err := buildDirectoryStructure("FooAPI", utils.TypeREST); err != nil { t.Fatalf("buildDirectoryStructure returned an error: %v", err) @@ -50,8 +56,8 @@ func TestBuildDirectoryStructureCreatesExpectedFiles(t *testing.T) { projectRoot := filepath.Join(tempDir, "FooAPI") expectedPaths := []string{ filepath.Join(projectRoot, ".api-platform", "config.yaml"), - filepath.Join(projectRoot, "api.yaml"), - filepath.Join(projectRoot, "gateway.yaml"), + filepath.Join(projectRoot, "metadata.yaml"), + filepath.Join(projectRoot, "runtime.yaml"), filepath.Join(projectRoot, "definition.yaml"), filepath.Join(projectRoot, "docs"), filepath.Join(projectRoot, "tests"), @@ -63,25 +69,90 @@ func TestBuildDirectoryStructureCreatesExpectedFiles(t *testing.T) { } } - gatewayYAML, err := os.ReadFile(filepath.Join(projectRoot, "gateway.yaml")) - if err != nil { - t.Fatalf("failed to read gateway.yaml: %v", err) + runtimeYAML := readFile(t, filepath.Join(projectRoot, "runtime.yaml")) + if !strings.Contains(runtimeYAML, "kind: RestApi") { + t.Fatalf("runtime.yaml does not contain the expected kind: %q", runtimeYAML) } - if !strings.Contains(string(gatewayYAML), `displayName: "FooAPI"`) { - t.Fatalf("gateway.yaml does not contain the expected display name") + if !strings.Contains(runtimeYAML, "displayName: FooAPI") { + t.Fatalf("runtime.yaml does not contain the expected display name: %q", runtimeYAML) } - if !strings.Contains(string(gatewayYAML), `context: "/petstore"`) { - t.Fatalf("gateway.yaml does not contain the expected context") + if !strings.Contains(runtimeYAML, "method: OPTIONS") { + t.Fatalf("runtime.yaml does not contain the OPTIONS operation: %q", runtimeYAML) } - definitionYAML, err := os.ReadFile(filepath.Join(projectRoot, "definition.yaml")) - if err != nil { - t.Fatalf("failed to read definition.yaml: %v", err) + metadataYAML := readFile(t, filepath.Join(projectRoot, "metadata.yaml")) + if !strings.Contains(metadataYAML, "apiVersion: management.api-platform.wso2.com/v1") { + t.Fatalf("metadata.yaml does not carry the management apiVersion: %q", metadataYAML) + } + if !strings.Contains(metadataYAML, "businessInformation:") { + t.Fatalf("management metadata.yaml should contain businessInformation: %q", metadataYAML) } - if !strings.Contains(string(definitionYAML), `"/*":`) { + + configYAML := readFile(t, filepath.Join(projectRoot, ".api-platform", "config.yaml")) + for _, want := range []string{"deploymentArtifact: ./runtime.yaml", "metadataFile: ./metadata.yaml", "definition: ./definition.yaml"} { + if !strings.Contains(configYAML, want) { + t.Fatalf("config.yaml missing %q: %s", want, configYAML) + } + } + + definitionYAML := readFile(t, filepath.Join(projectRoot, "definition.yaml")) + if !strings.Contains(definitionYAML, `"/*":`) { t.Fatalf("definition.yaml does not contain the wildcard path") } - if !strings.Contains(string(definitionYAML), "options:") { + if !strings.Contains(definitionYAML, "options:") { t.Fatalf("definition.yaml does not contain the OPTIONS operation") } } + +func TestBuildDirectoryStructureAIWorkspaceMetadata(t *testing.T) { + tempDir := chdirTemp(t) + + if err := buildDirectoryStructure("OpenAI Dev LLM", utils.TypeLLMProxy); err != nil { + t.Fatalf("buildDirectoryStructure returned an error: %v", err) + } + + projectRoot := filepath.Join(tempDir, "OpenAI Dev LLM") + metadataYAML := readFile(t, filepath.Join(projectRoot, "metadata.yaml")) + + for _, want := range []string{ + "apiVersion: ai-workspace.api-platform.wso2.com/v1alpha", + "kind: LlmProxy", + "name: openai-dev-llm", + "displayName: OpenAI Dev LLM", + } { + if !strings.Contains(metadataYAML, want) { + t.Fatalf("ai-workspace metadata.yaml missing %q: %s", want, metadataYAML) + } + } + + // The slim ai-workspace metadata must not carry the management-only blocks. + if strings.Contains(metadataYAML, "businessInformation:") || strings.Contains(metadataYAML, "endpoints:") { + t.Fatalf("ai-workspace metadata.yaml should not contain management-only fields: %s", metadataYAML) + } + + runtimeYAML := readFile(t, filepath.Join(projectRoot, "runtime.yaml")) + if !strings.Contains(runtimeYAML, "kind: LlmProxy") { + t.Fatalf("runtime.yaml should carry the artifact kind: %s", runtimeYAML) + } +} + +func TestBuildDirectoryStructureRejectsUnsupportedType(t *testing.T) { + chdirTemp(t) + + err := buildDirectoryStructure("FooAPI", "graphql") + if err == nil || !strings.Contains(err.Error(), "unsupported artifact type") { + t.Fatalf("expected unsupported artifact type error, got %v", err) + } + if !strings.Contains(err.Error(), "supported types:") { + t.Fatalf("expected error to list supported types, got %v", err) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + return string(data) +} diff --git a/cli/src/cmd/devportal/build.go b/cli/src/cmd/devportal/build.go index db28b6be79..bf41f0275c 100644 --- a/cli/src/cmd/devportal/build.go +++ b/cli/src/cmd/devportal/build.go @@ -18,6 +18,7 @@ package devportal import ( + "bytes" "fmt" "io" "os" @@ -25,27 +26,33 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/project" "github.com/wso2/api-platform/cli/utils" "gopkg.in/yaml.v3" ) const ( BuildCmdLiteral = "build" - BuildCmdExample = `# Build the api project for devportal -ap devportal build #Build the project in current directory + BuildCmdExample = `# Build the project in the current directory for devportal +ap devportal build -# Build the project in a specified directory -ap devportal build -f /path/to/project` +# Build a project in a specified directory +ap devportal build -f /path/to/project + +# Build and stamp a reference ID into each devportal manifest (declarative mode) +ap devportal build -f /path/to/project --reference-id 1ba42a09-45c0-40f8-a1bf-e4aa7cde1575 --gateway-type wso2/api-platform` ) var ( - buildProjectDir string + buildProjectDir string + buildReferenceID string + buildGatewayType string ) var buildCmd = &cobra.Command{ Use: BuildCmdLiteral, - Short: "Build the API project for devportal", - Long: "Build the API project located in the specified directory (or current directory if not specified) for deployment to the devportal.", + Short: "Build the project for devportal", + Long: "Build the project located in the specified directory (or current directory if not specified) for deployment to the devportal.", Example: BuildCmdExample, Run: func(cmd *cobra.Command, args []string) { if err := runBuildCommand(); err != nil { @@ -56,7 +63,9 @@ var buildCmd = &cobra.Command{ } func init() { - utils.AddStringFlag(buildCmd, utils.FlagFile, &buildProjectDir, "", "Path to the API project directory (defaults to current directory)") + utils.AddStringFlag(buildCmd, utils.FlagFile, &buildProjectDir, "", "Path to the project directory (defaults to current directory)") + utils.AddStringFlag(buildCmd, utils.FlagReferenceID, &buildReferenceID, "", "Reference ID to set under spec.referenceID in every devportal manifest") + utils.AddStringFlag(buildCmd, utils.FlagGatewayType, &buildGatewayType, "", "Gateway type to set under spec.gatewayType in every devportal manifest") } func runBuildCommand() error { @@ -71,36 +80,39 @@ func runBuildCommand() error { projectConfigDir := filepath.Join(projectRoot, ".api-platform") if _, err := os.Stat(projectConfigDir); os.IsNotExist(err) { - return fmt.Errorf("unable to find api project directory, please execute this command inside api project") + return fmt.Errorf("unable to find project directory, please execute this command inside a project") } else if err != nil { - return fmt.Errorf("failed to inspect api project directory: %w", err) + return fmt.Errorf("failed to inspect project directory: %w", err) } projectConfigPath := filepath.Join(projectConfigDir, "config.yaml") if _, err := os.Stat(projectConfigPath); os.IsNotExist(err) { - return fmt.Errorf("unable to find api project directory, please execute this command inside api project") + return fmt.Errorf("unable to find project directory, please execute this command inside a project") } else if err != nil { return fmt.Errorf("failed to inspect project config: %w", err) } - projectConfig, err := loadProjectConfig(projectConfigPath) + projectConfig, err := project.Load(projectConfigPath) if err != nil { return err } - isDevportalConfigExists := projectConfig.isDevportalConfigExists() - // create default devportal config if not exists and add to project config - if !isDevportalConfigExists { - portalConfig, err := projectConfig.createDefaultDevPortalConfig(projectRoot) + // create default devportal config if none exists and persist it to the project config + if len(projectConfig.DevPortals) == 0 { + portalConfig, err := createDefaultDevPortalConfig(projectConfig, projectRoot) if err != nil { return err } projectConfig.DevPortals = append(projectConfig.DevPortals, *portalConfig) - if err := saveProjectConfig(projectConfigPath, projectConfig); err != nil { + if err := project.Save(projectConfigPath, projectConfig); err != nil { return err } } + for i := range projectConfig.DevPortals { + normalizeDevPortalProjectConfig(&projectConfig.DevPortals[i]) + } + buildDir := filepath.Join(projectRoot, "build") if err := os.RemoveAll(buildDir); err != nil { return fmt.Errorf("failed to clean build directory: %w", err) @@ -109,7 +121,7 @@ func runBuildCommand() error { return fmt.Errorf("failed to recreate build directory: %w", err) } - zipPaths, err := buildDevPortalArchives(projectRoot, buildDir, projectConfig.DevPortals) + zipPaths, failures, err := buildDevPortalArchives(projectRoot, buildDir, projectConfig.DevPortals) if err != nil { return err } @@ -117,39 +129,24 @@ func runBuildCommand() error { for _, zipPath := range zipPaths { fmt.Printf("DevPortal build created at %s\n", zipPath) } - return nil -} - -type apiProjectConfig struct { - Version string `yaml:"version,omitempty"` - FilePaths apiProjectFilePaths `yaml:"filePaths,omitempty"` - GovernanceRulesets []string `yaml:"governanceRulesets,omitempty"` - AutoSync map[string]interface{} `yaml:"autoSync,omitempty"` - DevPortals []devPortalProjectConfig `yaml:"devportals,omitempty"` -} -type apiProjectFilePaths struct { - DeploymentArtifact string `yaml:"deploymentArtifact,omitempty"` - APIMetadata string `yaml:"apiMetadata,omitempty"` - APIDefinition string `yaml:"apiDefinition,omitempty"` - Docs string `yaml:"docs,omitempty"` - Tests string `yaml:"tests,omitempty"` -} - -type devPortalProjectConfig struct { - Name string `yaml:"name,omitempty"` - PortalRoot string `yaml:"portalRoot,omitempty"` - FilePaths devPortalProjectPaths `yaml:"filePaths,omitempty"` -} + if len(failures) > 0 { + messages := make([]string, 0, len(failures)) + for _, failure := range failures { + fmt.Fprintf(os.Stderr, "DevPortal build failed for %q: %v\n", failure.name, failure.err) + messages = append(messages, failure.err.Error()) + } + return fmt.Errorf("failed to build %d of %d devportal configuration(s): %s", + len(failures), len(projectConfig.DevPortals), strings.Join(messages, "; ")) + } -type devPortalProjectPaths struct { - APIMetadata string `yaml:"apiMetadata,omitempty"` - APIDefinition string `yaml:"apiDefinition,omitempty"` - Docs string `yaml:"docs,omitempty"` - Content string `yaml:"content,omitempty"` + return nil } +// projectAPIResource is the subset of the project metadata.yaml the devportal +// manifest is derived from. type projectAPIResource struct { + Kind string `yaml:"kind"` Metadata struct { Name string `yaml:"name"` } `yaml:"metadata"` @@ -157,7 +154,6 @@ type projectAPIResource struct { Description string `yaml:"description"` ReferenceID string `yaml:"referenceID"` GatewayType string `yaml:"gatewayType"` - Status string `yaml:"status"` Tags []string `yaml:"tags"` Labels []string `yaml:"labels"` BusinessInformation devPortalBusinessInformation `yaml:"businessInformation"` @@ -165,6 +161,8 @@ type projectAPIResource struct { } `yaml:"spec"` } +// gatewayResource is the subset of the project runtime.yaml the devportal +// manifest is derived from. type gatewayResource struct { Spec struct { DisplayName string `yaml:"displayName"` @@ -188,9 +186,7 @@ type devPortalManifestSpec struct { DisplayName string `yaml:"displayName"` Version string `yaml:"version"` Description string `yaml:"description"` - Provider string `yaml:"provider"` GatewayType string `yaml:"gatewayType"` - Status string `yaml:"status"` ReferenceID string `yaml:"referenceID"` Tags []string `yaml:"tags"` Labels []string `yaml:"labels"` @@ -213,50 +209,22 @@ type devPortalEndpoints struct { ProductionURL string `yaml:"productionUrl"` } -func loadProjectConfig(configPath string) (*apiProjectConfig, error) { - data, err := os.ReadFile(configPath) - if err != nil { - return nil, fmt.Errorf("failed to read project config: %w", err) - } - - var config apiProjectConfig - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("failed to parse project config: %w", err) - } - - normalizeAPIProjectConfig(&config) - return &config, nil +// failedPortal records a devportal config that could not be built so the others +// can still be archived and the failures reported together. +type failedPortal struct { + name string + err error } -func saveProjectConfig(configPath string, config *apiProjectConfig) error { - data, err := yaml.Marshal(config) - if err != nil { - return fmt.Errorf("failed to marshal project config: %w", err) - } - - if err := os.WriteFile(configPath, data, 0644); err != nil { - return fmt.Errorf("failed to save project config: %w", err) - } - - return nil -} - -func (c *apiProjectConfig) isDevportalConfigExists() bool { - if len(c.DevPortals) == 0 { - return false - } - return true -} - -func (c *apiProjectConfig) createDefaultDevPortalConfig(projectRoot string) (*devPortalProjectConfig, error) { - portalConfig := &devPortalProjectConfig{ +func createDefaultDevPortalConfig(projectConfig *project.Config, projectRoot string) (*project.PortalConfig, error) { + portalConfig := &project.PortalConfig{ Name: "default", PortalRoot: "./devportal", - FilePaths: devPortalProjectPaths{ - APIMetadata: "./devportal.yaml", - APIDefinition: "./definition.yaml", - Docs: "./docs", - Content: "./content", + FilePaths: project.PortalFilePaths{ + MetadataFile: "./devportal.yaml", + Definition: "./definition.yaml", + Docs: "./docs", + Content: "./content", }, } @@ -265,8 +233,8 @@ func (c *apiProjectConfig) createDefaultDevPortalConfig(projectRoot string) (*de return nil, fmt.Errorf("failed to create devportal directory: %w", err) } - definitionSource := resolveProjectPath(projectRoot, c.FilePaths.APIDefinition) - if err := ensureWithinProjectRoot(projectRoot, definitionSource, portalConfig.Name, "apiDefinition"); err != nil { + definitionSource := resolveProjectPath(projectRoot, projectConfig.FilePaths.Definition) + if err := ensureWithinProjectRoot(projectRoot, definitionSource, portalConfig.Name, "definition"); err != nil { return nil, err } definitionTarget := filepath.Join(portalRoot, "definition.yaml") @@ -274,7 +242,7 @@ func (c *apiProjectConfig) createDefaultDevPortalConfig(projectRoot string) (*de return nil, err } - docsSource := resolveProjectPath(projectRoot, c.FilePaths.Docs) + docsSource := resolveProjectPath(projectRoot, projectConfig.FilePaths.Docs) if err := ensureWithinProjectRoot(projectRoot, docsSource, portalConfig.Name, "docs"); err != nil { return nil, err } @@ -288,12 +256,12 @@ func (c *apiProjectConfig) createDefaultDevPortalConfig(projectRoot string) (*de return nil, fmt.Errorf("failed to create devportal content directory: %w", err) } - manifest, err := buildDefaultDevPortalManifest(projectRoot, c) + manifest, err := buildDefaultDevPortalManifest(projectRoot, projectConfig) if err != nil { return nil, err } - manifestData, err := yaml.Marshal(manifest) + manifestData, err := marshalManifest(manifest) if err != nil { return nil, fmt.Errorf("failed to marshal devportal manifest: %w", err) } @@ -306,34 +274,34 @@ func (c *apiProjectConfig) createDefaultDevPortalConfig(projectRoot string) (*de return portalConfig, nil } -func buildDefaultDevPortalManifest(projectRoot string, projectConfig *apiProjectConfig) (*devPortalManifest, error) { - apiMetadataPath := resolveProjectPath(projectRoot, projectConfig.FilePaths.APIMetadata) - if err := ensureWithinProjectRoot(projectRoot, apiMetadataPath, "default", "apiMetadata"); err != nil { +func buildDefaultDevPortalManifest(projectRoot string, projectConfig *project.Config) (*devPortalManifest, error) { + metadataPath := resolveProjectPath(projectRoot, projectConfig.FilePaths.MetadataFile) + if err := ensureWithinProjectRoot(projectRoot, metadataPath, "default", "metadataFile"); err != nil { return nil, err } - gatewayPath := resolveProjectPath(projectRoot, projectConfig.FilePaths.DeploymentArtifact) - if err := ensureWithinProjectRoot(projectRoot, gatewayPath, "default", "deploymentArtifact"); err != nil { + runtimePath := resolveProjectPath(projectRoot, projectConfig.FilePaths.DeploymentArtifact) + if err := ensureWithinProjectRoot(projectRoot, runtimePath, "default", "deploymentArtifact"); err != nil { return nil, err } - apiMetadataData, err := os.ReadFile(apiMetadataPath) + metadataData, err := os.ReadFile(metadataPath) if err != nil { - return nil, fmt.Errorf("failed to read api metadata: %w", err) + return nil, fmt.Errorf("failed to read project metadata: %w", err) } var apiMetadata projectAPIResource - if err := yaml.Unmarshal(apiMetadataData, &apiMetadata); err != nil { - return nil, fmt.Errorf("failed to parse api metadata: %w", err) + if err := yaml.Unmarshal(metadataData, &apiMetadata); err != nil { + return nil, fmt.Errorf("failed to parse project metadata: %w", err) } - gatewayData, err := os.ReadFile(gatewayPath) + runtimeData, err := os.ReadFile(runtimePath) if err != nil { - return nil, fmt.Errorf("failed to read gateway artifact: %w", err) + return nil, fmt.Errorf("failed to read deployment artifact: %w", err) } var gateway gatewayResource - if err := yaml.Unmarshal(gatewayData, &gateway); err != nil { - return nil, fmt.Errorf("failed to parse gateway artifact: %w", err) + if err := yaml.Unmarshal(runtimeData, &gateway); err != nil { + return nil, fmt.Errorf("failed to parse deployment artifact: %w", err) } tags := apiMetadata.Spec.Tags @@ -348,17 +316,17 @@ func buildDefaultDevPortalManifest(projectRoot string, projectConfig *apiProject gatewayType := apiMetadata.Spec.GatewayType if gatewayType == "" { - gatewayType = "wso2/api-platform" + gatewayType = project.DefaultGatewayType } - status := strings.TrimSpace(apiMetadata.Spec.Status) - if status == "" { - status = "PUBLISHED" + kind := strings.TrimSpace(apiMetadata.Kind) + if kind == "" { + kind = "RestApi" } return &devPortalManifest{ APIVersion: "devportal.api-platform.wso2.com/v1", - Kind: "RestApi", + Kind: kind, Metadata: devPortalManifestMeta{ Name: strings.TrimSpace(apiMetadata.Metadata.Name), }, @@ -366,9 +334,7 @@ func buildDefaultDevPortalManifest(projectRoot string, projectConfig *apiProject DisplayName: strings.TrimSpace(gateway.Spec.DisplayName), Version: strings.TrimSpace(gateway.Spec.Version), Description: strings.TrimSpace(apiMetadata.Spec.Description), - Provider: "WSO2", GatewayType: gatewayType, - Status: status, ReferenceID: strings.TrimSpace(apiMetadata.Spec.ReferenceID), Tags: tags, Labels: labels, @@ -391,36 +357,18 @@ func resolveProjectPath(projectRoot, pathValue string) string { return filepath.Join(projectRoot, filepath.Clean(trimmed)) } -func normalizeAPIProjectConfig(config *apiProjectConfig) { - if strings.TrimSpace(config.FilePaths.DeploymentArtifact) == "" { - config.FilePaths.DeploymentArtifact = "./gateway.yaml" - } - if strings.TrimSpace(config.FilePaths.APIMetadata) == "" { - config.FilePaths.APIMetadata = "./api.yaml" - } - if strings.TrimSpace(config.FilePaths.APIDefinition) == "" { - config.FilePaths.APIDefinition = "./definition.yaml" - } - if strings.TrimSpace(config.FilePaths.Docs) == "" { - config.FilePaths.Docs = "./docs" - } - if strings.TrimSpace(config.FilePaths.Tests) == "" { - config.FilePaths.Tests = "./tests" - } -} - -func normalizeDevPortalProjectConfig(config *devPortalProjectConfig) { +func normalizeDevPortalProjectConfig(config *project.PortalConfig) { if strings.TrimSpace(config.Name) == "" { config.Name = "default" } if strings.TrimSpace(config.PortalRoot) == "" { config.PortalRoot = "./devportal" } - if strings.TrimSpace(config.FilePaths.APIMetadata) == "" { - config.FilePaths.APIMetadata = "./devportal.yaml" + if strings.TrimSpace(config.FilePaths.MetadataFile) == "" { + config.FilePaths.MetadataFile = "./devportal.yaml" } - if strings.TrimSpace(config.FilePaths.APIDefinition) == "" { - config.FilePaths.APIDefinition = "./definition.yaml" + if strings.TrimSpace(config.FilePaths.Definition) == "" { + config.FilePaths.Definition = "./definition.yaml" } if strings.TrimSpace(config.FilePaths.Docs) == "" { config.FilePaths.Docs = "./docs" @@ -430,41 +378,116 @@ func normalizeDevPortalProjectConfig(config *devPortalProjectConfig) { } } -func buildDevPortalArchives(projectRoot, buildDir string, portalConfigs []devPortalProjectConfig) ([]string, error) { +func buildDevPortalArchives(projectRoot, buildDir string, portalConfigs []project.PortalConfig) ([]string, []failedPortal, error) { if err := ensureUniqueDevPortalZipNames(portalConfigs); err != nil { - return nil, err + return nil, nil, err } zipPaths := make([]string, 0, len(portalConfigs)) + failures := make([]failedPortal, 0) for i := range portalConfigs { - normalizeDevPortalProjectConfig(&portalConfigs[i]) - - if err := validateDevPortalConfig(projectRoot, &portalConfigs[i]); err != nil { - return nil, err - } - - stagingDir, err := createDevPortalArchiveStagingDir(projectRoot, buildDir, &portalConfigs[i]) + zipPath, err := buildSingleDevPortalArchive(projectRoot, buildDir, &portalConfigs[i]) if err != nil { - return nil, err + failures = append(failures, failedPortal{name: portalConfigs[i].Name, err: err}) + continue } + zipPaths = append(zipPaths, zipPath) + } - zipPath := filepath.Join(buildDir, buildDevPortalZipFileName(portalConfigs[i].Name)) - if err := utils.ZipDirectory(stagingDir, zipPath); err != nil { - _ = os.RemoveAll(stagingDir) - return nil, fmt.Errorf("failed to build devportal archive for %s: %w", portalConfigs[i].Name, err) - } - if err := os.RemoveAll(stagingDir); err != nil { - return nil, fmt.Errorf("failed to clean staging directory for devportal config %q: %w", portalConfigs[i].Name, err) - } + return zipPaths, failures, nil +} - zipPaths = append(zipPaths, zipPath) +// buildSingleDevPortalArchive validates one devportal config, stamps any +// --reference-id / --gateway-type overrides into its manifest, and zips it. +// Returning an error here drops only this config; the caller keeps building the +// rest. +func buildSingleDevPortalArchive(projectRoot, buildDir string, portalConfig *project.PortalConfig) (string, error) { + if err := validateDevPortalConfig(projectRoot, portalConfig); err != nil { + return "", err + } + + if err := applyManifestOverrides(projectRoot, portalConfig); err != nil { + return "", err } - return zipPaths, nil + stagingDir, err := createDevPortalArchiveStagingDir(projectRoot, buildDir, portalConfig) + if err != nil { + return "", err + } + + zipPath := filepath.Join(buildDir, buildDevPortalZipFileName(portalConfig.Name)) + if err := utils.ZipDirectory(stagingDir, zipPath); err != nil { + _ = os.RemoveAll(stagingDir) + return "", fmt.Errorf("failed to build devportal archive for %s: %w", portalConfig.Name, err) + } + if err := os.RemoveAll(stagingDir); err != nil { + return "", fmt.Errorf("failed to clean staging directory for devportal config %q: %w", portalConfig.Name, err) + } + + return zipPath, nil +} + +// applyManifestOverrides stamps the build-time --reference-id / --gateway-type +// flags into the devportal manifest under spec.referenceID / spec.gatewayType. +// The manifest itself carries no reference ID by default; supplying one at +// build time lets the same artifact be published to different devportals (each +// wired to a different gateway). Existing manifest fields and ordering are +// preserved. +func applyManifestOverrides(projectRoot string, portalConfig *project.PortalConfig) error { + referenceID := strings.TrimSpace(buildReferenceID) + gatewayType := strings.TrimSpace(buildGatewayType) + if referenceID == "" && gatewayType == "" { + return nil + } + + manifestPath := resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.MetadataFile) + if err := ensureWithinProjectRoot(projectRoot, manifestPath, portalConfig.Name, "metadataFile"); err != nil { + return err + } + + data, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("failed to read devportal manifest for config %q: %w", portalConfig.Name, err) + } + + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return fmt.Errorf("failed to parse devportal manifest for config %q: %w", portalConfig.Name, err) + } + + root := &doc + if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { + root = root.Content[0] + } + if root.Kind != yaml.MappingNode { + return fmt.Errorf("devportal manifest for config %q is not a mapping", portalConfig.Name) + } + + spec := mappingValueNode(root, "spec") + if spec == nil { + spec = &yaml.Node{Kind: yaml.MappingNode} + setMappingChild(root, "spec", spec) + } + if referenceID != "" { + setMappingScalar(spec, "referenceID", referenceID) + } + if gatewayType != "" { + setMappingScalar(spec, "gatewayType", gatewayType) + } + + out, err := marshalNode(&doc) + if err != nil { + return fmt.Errorf("failed to marshal devportal manifest for config %q: %w", portalConfig.Name, err) + } + if err := os.WriteFile(manifestPath, out, 0644); err != nil { + return fmt.Errorf("failed to write devportal manifest for config %q: %w", portalConfig.Name, err) + } + + return nil } -func validateDevPortalConfig(projectRoot string, portalConfig *devPortalProjectConfig) error { +func validateDevPortalConfig(projectRoot string, portalConfig *project.PortalConfig) error { portalRoot := resolveProjectPath(projectRoot, portalConfig.PortalRoot) if err := ensureWithinProjectRoot(projectRoot, portalRoot, portalConfig.Name, "portalRoot"); err != nil { return err @@ -478,8 +501,8 @@ func validateDevPortalConfig(projectRoot string, portalConfig *devPortalProjectC path string isDir bool }{ - {label: "apiMetadata", path: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.APIMetadata), isDir: false}, - {label: "apiDefinition", path: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.APIDefinition), isDir: false}, + {label: "metadataFile", path: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.MetadataFile), isDir: false}, + {label: "definition", path: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.Definition), isDir: false}, {label: "docs", path: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.Docs), isDir: true}, {label: "content", path: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.Content), isDir: true}, } @@ -572,7 +595,7 @@ func ensurePathExists(path string, wantDir bool, portalName, fieldName string) e return nil } -func createDevPortalArchiveStagingDir(projectRoot, buildDir string, portalConfig *devPortalProjectConfig) (string, error) { +func createDevPortalArchiveStagingDir(projectRoot, buildDir string, portalConfig *project.PortalConfig) (string, error) { stagingRoot, err := os.MkdirTemp(buildDir, "devportal-build-*") if err != nil { return "", fmt.Errorf("failed to create staging directory for devportal config %q: %w", portalConfig.Name, err) @@ -590,13 +613,13 @@ func createDevPortalArchiveStagingDir(projectRoot, buildDir string, portalConfig isDir bool }{ { - source: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.APIMetadata), - target: filepath.Join(stagingRoot, archiveRelativePath(portalConfig.FilePaths.APIMetadata)), + source: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.MetadataFile), + target: filepath.Join(stagingRoot, archiveRelativePath(portalConfig.FilePaths.MetadataFile)), isDir: false, }, { - source: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.APIDefinition), - target: filepath.Join(stagingRoot, archiveRelativePath(portalConfig.FilePaths.APIDefinition)), + source: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.Definition), + target: filepath.Join(stagingRoot, archiveRelativePath(portalConfig.FilePaths.Definition)), isDir: false, }, { @@ -628,7 +651,7 @@ func createDevPortalArchiveStagingDir(projectRoot, buildDir string, portalConfig return stagingRoot, nil } -func resolvePortalConfigPath(projectRoot string, portalConfig *devPortalProjectConfig, pathValue string) string { +func resolvePortalConfigPath(projectRoot string, portalConfig *project.PortalConfig, pathValue string) string { portalRoot := resolveProjectPath(projectRoot, portalConfig.PortalRoot) trimmed := strings.TrimSpace(pathValue) if trimmed == "" { @@ -662,7 +685,7 @@ func archiveRelativePath(pathValue string) string { // ensureUniqueDevPortalZipNames fails fast when two devportal configs sanitize // to the same archive filename, which would otherwise silently overwrite an // earlier artifact during the build loop. -func ensureUniqueDevPortalZipNames(portalConfigs []devPortalProjectConfig) error { +func ensureUniqueDevPortalZipNames(portalConfigs []project.PortalConfig) error { seen := make(map[string]string, len(portalConfigs)) for i := range portalConfigs { displayName := strings.TrimSpace(portalConfigs[i].Name) @@ -750,3 +773,63 @@ func copyDirectory(sourceDir, targetDir string) error { return copyFile(path, targetPath) }) } + +// marshalManifest renders a generated manifest with the 2-space indentation +// used across the project's YAML artifacts. +func marshalManifest(v interface{}) ([]byte, error) { + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + if err := encoder.Encode(v); err != nil { + return nil, err + } + if err := encoder.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// marshalNode renders an already-parsed YAML node, preserving its structure and +// comments while applying the project's 2-space indentation. +func marshalNode(node *yaml.Node) ([]byte, error) { + return marshalManifest(node) +} + +// mappingValueNode returns the value node for key in a mapping node, or nil. +func mappingValueNode(mapping *yaml.Node, key string) *yaml.Node { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + return mapping.Content[i+1] + } + } + return nil +} + +// setMappingChild sets (or replaces) the value node for key in a mapping node. +func setMappingChild(mapping *yaml.Node, key string, value *yaml.Node) { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + mapping.Content[i+1] = value + return + } + } + mapping.Content = append(mapping.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, + value) +} + +// setMappingScalar sets a string-valued key in a mapping node, updating it in +// place if present so surrounding fields and ordering are preserved. +func setMappingScalar(mapping *yaml.Node, key, value string) { + if mapping.Kind != yaml.MappingNode { + mapping.Kind = yaml.MappingNode + } + if existing := mappingValueNode(mapping, key); existing != nil { + existing.Kind = yaml.ScalarNode + existing.Tag = "!!str" + existing.Value = value + existing.Style = 0 + return + } + setMappingChild(mapping, key, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}) +} diff --git a/cli/src/cmd/devportal/build_test.go b/cli/src/cmd/devportal/build_test.go index 589de2eb16..0fdd954f88 100644 --- a/cli/src/cmd/devportal/build_test.go +++ b/cli/src/cmd/devportal/build_test.go @@ -22,10 +22,12 @@ import ( "path/filepath" "strings" "testing" + + "github.com/wso2/api-platform/cli/internal/project" ) func TestEnsureUniqueDevPortalZipNames_DetectsCollision(t *testing.T) { - configs := []devPortalProjectConfig{ + configs := []project.PortalConfig{ {Name: "My Portal"}, {Name: "my_portal"}, } @@ -40,7 +42,7 @@ func TestEnsureUniqueDevPortalZipNames_DetectsCollision(t *testing.T) { } func TestEnsureUniqueDevPortalZipNames_AllowsDistinctNames(t *testing.T) { - configs := []devPortalProjectConfig{ + configs := []project.PortalConfig{ {Name: ""}, {Name: "eu"}, {Name: "us"}, @@ -68,9 +70,9 @@ func TestEnsureWithinProjectRoot_AllowsContainedPaths(t *testing.T) { projectRoot := t.TempDir() cases := []string{ - projectRoot, // the root itself - filepath.Join(projectRoot, "devportal"), // nested dir - filepath.Join(projectRoot, "devportal", "api.yaml"), // nested file + projectRoot, // the root itself + filepath.Join(projectRoot, "devportal"), // nested dir + filepath.Join(projectRoot, "devportal", "metadata.yaml"), // nested file } for _, path := range cases { if err := ensureWithinProjectRoot(projectRoot, path, "default", "portalRoot"); err != nil { @@ -90,7 +92,7 @@ func TestEnsureWithinProjectRoot_RejectsSymlinkEscape(t *testing.T) { t.Skipf("symlinks unsupported on this platform: %v", err) } - err := ensureWithinProjectRoot(projectRoot, filepath.Join(link, "secret.yaml"), "default", "apiMetadata") + err := ensureWithinProjectRoot(projectRoot, filepath.Join(link, "secret.yaml"), "default", "metadataFile") if err == nil || !strings.Contains(err.Error(), "resolves outside the project root") { t.Fatalf("expected symlink escape to be rejected, got %v", err) } @@ -98,7 +100,7 @@ func TestEnsureWithinProjectRoot_RejectsSymlinkEscape(t *testing.T) { func TestValidateDevPortalConfig_RejectsEscapingPortalRoot(t *testing.T) { projectRoot := t.TempDir() - portalConfig := &devPortalProjectConfig{Name: "default", PortalRoot: "../../etc"} + portalConfig := &project.PortalConfig{Name: "default", PortalRoot: "../../etc"} err := validateDevPortalConfig(projectRoot, portalConfig) if err == nil { @@ -111,10 +113,10 @@ func TestValidateDevPortalConfig_RejectsEscapingPortalRoot(t *testing.T) { func TestBuildDefaultDevPortalManifest_RejectsEscapingSourcePath(t *testing.T) { projectRoot := t.TempDir() - projectConfig := &apiProjectConfig{ - FilePaths: apiProjectFilePaths{ - APIMetadata: "../../../etc/passwd", - DeploymentArtifact: "./gateway.yaml", + projectConfig := &project.Config{ + FilePaths: project.FilePaths{ + MetadataFile: "../../../etc/passwd", + DeploymentArtifact: "./runtime.yaml", }, } @@ -126,31 +128,31 @@ func TestBuildDefaultDevPortalManifest_RejectsEscapingSourcePath(t *testing.T) { func TestCreateDefaultDevPortalConfig_RejectsEscapingSourcePath(t *testing.T) { projectRoot := t.TempDir() - projectConfig := &apiProjectConfig{ - FilePaths: apiProjectFilePaths{ - APIDefinition: "../../outside/definition.yaml", - Docs: "./docs", + projectConfig := &project.Config{ + FilePaths: project.FilePaths{ + Definition: "../../outside/definition.yaml", + Docs: "./docs", }, } - _, err := projectConfig.createDefaultDevPortalConfig(projectRoot) + _, err := createDefaultDevPortalConfig(projectConfig, projectRoot) if err == nil || !strings.Contains(err.Error(), "resolves outside the project root") { t.Fatalf("expected containment error, got %v", err) } } -func TestRunBuildCommand_RequiresAPIProjectDirectory(t *testing.T) { +func TestRunBuildCommand_RequiresProjectDirectory(t *testing.T) { workDir := t.TempDir() buildProjectDir = workDir err := runBuildCommand() - if err == nil || err.Error() != "unable to find api project directory, please execute this command inside api project" { - t.Fatalf("expected api project directory error, got %v", err) + if err == nil || err.Error() != "unable to find project directory, please execute this command inside a project" { + t.Fatalf("expected project directory error, got %v", err) } } func TestRunBuildCommand_CreatesDefaultDevPortalArtifacts(t *testing.T) { - projectRoot := createAPIProjectFixture(t) + projectRoot := createProjectFixture(t) buildProjectDir = projectRoot if err := runBuildCommand(); err != nil { @@ -170,7 +172,7 @@ func TestRunBuildCommand_CreatesDefaultDevPortalArtifacts(t *testing.T) { } } - projectConfig, err := loadProjectConfig(filepath.Join(projectRoot, ".api-platform", "config.yaml")) + projectConfig, err := project.Load(filepath.Join(projectRoot, ".api-platform", "config.yaml")) if err != nil { t.Fatalf("failed to load updated project config: %v", err) } @@ -187,22 +189,49 @@ func TestRunBuildCommand_CreatesDefaultDevPortalArtifacts(t *testing.T) { } } -func TestRunBuildCommand_BuildsMultipleConfiguredDevPortalsAndCleansBuildDir(t *testing.T) { - projectRoot := createAPIProjectFixture(t) +func TestRunBuildCommand_StampsReferenceIDAndGatewayType(t *testing.T) { + projectRoot := createProjectFixture(t) + buildProjectDir = projectRoot - defaultPortalRoot := filepath.Join(projectRoot, "devportal") - petstorePortalRoot := filepath.Join(projectRoot, "petstore") - if err := os.MkdirAll(filepath.Join(defaultPortalRoot, "docs"), 0755); err != nil { - t.Fatalf("failed to create docs: %v", err) + buildReferenceID = "1ba42a09-45c0-40f8-a1bf-e4aa7cde1575" + buildGatewayType = "wso2/api-platform" + t.Cleanup(func() { + buildReferenceID = "" + buildGatewayType = "" + }) + + if err := runBuildCommand(); err != nil { + t.Fatalf("unexpected error: %v", err) } - if err := os.MkdirAll(filepath.Join(defaultPortalRoot, "content"), 0755); err != nil { - t.Fatalf("failed to create content: %v", err) + + manifestData, readErr := os.ReadFile(filepath.Join(projectRoot, "devportal", "devportal.yaml")) + if readErr != nil { + t.Fatalf("failed to read manifest: %v", readErr) } - if err := os.MkdirAll(filepath.Join(petstorePortalRoot, "docs"), 0755); err != nil { - t.Fatalf("failed to create docs: %v", err) + for _, want := range []string{ + "referenceID: 1ba42a09-45c0-40f8-a1bf-e4aa7cde1575", + "gatewayType: wso2/api-platform", + } { + if !strings.Contains(string(manifestData), want) { + t.Fatalf("expected stamped manifest to contain %q, got %q", want, string(manifestData)) + } } - if err := os.MkdirAll(filepath.Join(petstorePortalRoot, "content"), 0755); err != nil { - t.Fatalf("failed to create content: %v", err) +} + +func TestRunBuildCommand_BuildsMultipleConfiguredDevPortalsAndCleansBuildDir(t *testing.T) { + projectRoot := createProjectFixture(t) + + defaultPortalRoot := filepath.Join(projectRoot, "devportal") + petstorePortalRoot := filepath.Join(projectRoot, "petstore") + for _, dir := range []string{ + filepath.Join(defaultPortalRoot, "docs"), + filepath.Join(defaultPortalRoot, "content"), + filepath.Join(petstorePortalRoot, "docs"), + filepath.Join(petstorePortalRoot, "content"), + } { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("failed to create dir %s: %v", dir, err) + } } for _, path := range []string{ @@ -214,34 +243,28 @@ func TestRunBuildCommand_BuildsMultipleConfiguredDevPortalsAndCleansBuildDir(t * } } - if err := saveProjectConfig(filepath.Join(projectRoot, ".api-platform", "config.yaml"), &apiProjectConfig{ - Version: "1.0.0", - FilePaths: apiProjectFilePaths{ - DeploymentArtifact: "./gateway.yaml", - APIMetadata: "./api.yaml", - APIDefinition: "./definition.yaml", - Docs: "./docs", - Tests: "./tests", - }, - DevPortals: []devPortalProjectConfig{ + if err := project.Save(filepath.Join(projectRoot, ".api-platform", "config.yaml"), &project.Config{ + Version: "1.0.0", + FilePaths: project.DefaultFilePaths(), + DevPortals: []project.PortalConfig{ { Name: "default", PortalRoot: "./devportal", - FilePaths: devPortalProjectPaths{ - APIMetadata: "./devportal.yaml", - APIDefinition: "./../definition.yaml", - Docs: "./docs", - Content: "./content", + FilePaths: project.PortalFilePaths{ + MetadataFile: "./devportal.yaml", + Definition: "./../definition.yaml", + Docs: "./docs", + Content: "./content", }, }, { Name: "petstore", PortalRoot: "./petstore", - FilePaths: devPortalProjectPaths{ - APIMetadata: "./devportal.yaml", - APIDefinition: "./../definition.yaml", - Docs: "./docs", - Content: "./content", + FilePaths: project.PortalFilePaths{ + MetadataFile: "./devportal.yaml", + Definition: "./../definition.yaml", + Docs: "./docs", + Content: "./content", }, }, }, @@ -276,8 +299,75 @@ func TestRunBuildCommand_BuildsMultipleConfiguredDevPortalsAndCleansBuildDir(t * } } +func TestRunBuildCommand_PartialSuccessReportsFailureAndBuildsRest(t *testing.T) { + projectRoot := createProjectFixture(t) + + // "default" is fully set up; "broken" is missing its required content dir. + defaultPortalRoot := filepath.Join(projectRoot, "devportal") + brokenPortalRoot := filepath.Join(projectRoot, "broken") + for _, dir := range []string{ + filepath.Join(defaultPortalRoot, "docs"), + filepath.Join(defaultPortalRoot, "content"), + filepath.Join(brokenPortalRoot, "docs"), + } { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("failed to create dir %s: %v", dir, err) + } + } + for _, path := range []string{ + filepath.Join(defaultPortalRoot, "devportal.yaml"), + filepath.Join(brokenPortalRoot, "devportal.yaml"), + } { + if err := os.WriteFile(path, []byte("apiVersion: devportal.api-platform.wso2.com/v1\nkind: RestApi\n"), 0644); err != nil { + t.Fatalf("failed to write portal manifest: %v", err) + } + } + + if err := project.Save(filepath.Join(projectRoot, ".api-platform", "config.yaml"), &project.Config{ + FilePaths: project.DefaultFilePaths(), + DevPortals: []project.PortalConfig{ + { + Name: "default", + PortalRoot: "./devportal", + FilePaths: project.PortalFilePaths{ + MetadataFile: "./devportal.yaml", + Definition: "./../definition.yaml", + Docs: "./docs", + Content: "./content", + }, + }, + { + Name: "broken", + PortalRoot: "./broken", + FilePaths: project.PortalFilePaths{ + MetadataFile: "./devportal.yaml", + Definition: "./../definition.yaml", + Docs: "./docs", + Content: "./content", + }, + }, + }, + }); err != nil { + t.Fatalf("failed to save project config: %v", err) + } + + buildProjectDir = projectRoot + err := runBuildCommand() + if err == nil || !strings.Contains(err.Error(), `devportal config "broken" is invalid: content path does not exist`) { + t.Fatalf("expected failure for the broken config, got %v", err) + } + + // The healthy config must still have produced its archive. + if _, statErr := os.Stat(filepath.Join(projectRoot, "build", "devportal.zip")); statErr != nil { + t.Fatalf("expected the healthy config to still build its archive: %v", statErr) + } + if _, statErr := os.Stat(filepath.Join(projectRoot, "build", "devportal_broken.zip")); !os.IsNotExist(statErr) { + t.Fatalf("did not expect an archive for the broken config, got err=%v", statErr) + } +} + func TestRunBuildCommand_ValidatesRequiredContentPath(t *testing.T) { - projectRoot := createAPIProjectFixture(t) + projectRoot := createProjectFixture(t) portalRoot := filepath.Join(projectRoot, "devportal") if err := os.MkdirAll(filepath.Join(portalRoot, "docs"), 0755); err != nil { @@ -287,23 +377,17 @@ func TestRunBuildCommand_ValidatesRequiredContentPath(t *testing.T) { t.Fatalf("failed to write manifest: %v", err) } - if err := saveProjectConfig(filepath.Join(projectRoot, ".api-platform", "config.yaml"), &apiProjectConfig{ - FilePaths: apiProjectFilePaths{ - DeploymentArtifact: "./gateway.yaml", - APIMetadata: "./api.yaml", - APIDefinition: "./definition.yaml", - Docs: "./docs", - Tests: "./tests", - }, - DevPortals: []devPortalProjectConfig{ + if err := project.Save(filepath.Join(projectRoot, ".api-platform", "config.yaml"), &project.Config{ + FilePaths: project.DefaultFilePaths(), + DevPortals: []project.PortalConfig{ { Name: "default", PortalRoot: "./devportal", - FilePaths: devPortalProjectPaths{ - APIMetadata: "./devportal.yaml", - APIDefinition: "./../definition.yaml", - Docs: "./docs", - Content: "./content", + FilePaths: project.PortalFilePaths{ + MetadataFile: "./devportal.yaml", + Definition: "./../definition.yaml", + Docs: "./docs", + Content: "./content", }, }, }, @@ -318,7 +402,7 @@ func TestRunBuildCommand_ValidatesRequiredContentPath(t *testing.T) { } } -func createAPIProjectFixture(t *testing.T) string { +func createProjectFixture(t *testing.T) string { t.Helper() projectRoot := t.TempDir() @@ -333,9 +417,9 @@ func createAPIProjectFixture(t *testing.T) string { } files := map[string]string{ - filepath.Join(projectRoot, ".api-platform", "config.yaml"): "version: 1.0.0\nfilePaths:\n deploymentArtifact: ./gateway.yaml\n apiMetadata: ./api.yaml\n apiDefinition: ./definition.yaml\n docs: ./docs\n tests: ./tests\n", - filepath.Join(projectRoot, "api.yaml"): "apiVersion: management.api-platform.wso2.com/v1\nkind: Api\nmetadata:\n name: foo-1.0.0\n", - filepath.Join(projectRoot, "gateway.yaml"): "apiVersion: gateway.api-platform.wso2.com/v1\nkind: RestApi\nspec:\n displayName: Petstore API\n version: 1.0.0\n subscriptionPlans:\n - gold\n - silver\n", + filepath.Join(projectRoot, ".api-platform", "config.yaml"): "version: 1.0.0\nfilePaths:\n deploymentArtifact: ./runtime.yaml\n metadataFile: ./metadata.yaml\n definition: ./definition.yaml\n docs: ./docs\n tests: ./tests\n", + filepath.Join(projectRoot, "metadata.yaml"): "apiVersion: management.api-platform.wso2.com/v1\nkind: RestApi\nmetadata:\n name: foo-1.0.0\nspec:\n referenceID: \"\"\n", + filepath.Join(projectRoot, "runtime.yaml"): "apiVersion: gateway.api-platform.wso2.com/v1\nkind: RestApi\nspec:\n displayName: Petstore API\n version: 1.0.0\n subscriptionPlans:\n - gold\n - silver\n", filepath.Join(projectRoot, "definition.yaml"): "openapi: 3.0.0\ninfo:\n title: Petstore API\n", filepath.Join(projectRoot, "docs", "README.md"): "# Docs\n", } diff --git a/cli/src/internal/project/artifact.go b/cli/src/internal/project/artifact.go new file mode 100644 index 0000000000..c94e85c041 --- /dev/null +++ b/cli/src/internal/project/artifact.go @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package project + +import ( + "github.com/wso2/api-platform/cli/utils" +) + +// apiVersions stamped into generated manifests, keyed by the platform plane the +// artifact belongs to. +const ( + ManagementAPIVersion = "management.api-platform.wso2.com/v1" + AIWorkspaceAPIVersion = "ai-workspace.api-platform.wso2.com/v1alpha" + GatewayAPIVersion = "gateway.api-platform.wso2.com/v1" +) + +// DefaultGatewayType is used when no gateway type is supplied for an artifact. +const DefaultGatewayType = "wso2/api-platform" + +// SupportedArtifactTypes returns the artifact types the CLI can scaffold and +// build, in display order. +func SupportedArtifactTypes() []string { + return []string{ + utils.TypeREST, + utils.TypeLLMProxy, + utils.TypeLLMProvider, + utils.TypeLLMProviderTemplate, + utils.TypeMCPProxy, + } +} + +// IsValidArtifactType reports whether artifactType is one the CLI supports. +func IsValidArtifactType(artifactType string) bool { + for _, t := range SupportedArtifactTypes() { + if artifactType == t { + return true + } + } + return false +} + +// IsAIWorkspaceType reports whether the artifact type belongs to the +// ai-workspace plane (LLM proxies/providers, MCP proxies) rather than the +// management plane (REST APIs). +func IsAIWorkspaceType(artifactType string) bool { + switch artifactType { + case utils.TypeLLMProxy, utils.TypeLLMProvider, utils.TypeLLMProviderTemplate, utils.TypeMCPProxy: + return true + default: + return false + } +} + +// ArtifactKind maps an artifact type to its manifest kind. +func ArtifactKind(artifactType string) string { + switch artifactType { + case utils.TypeREST: + return "RestApi" + case utils.TypeLLMProxy: + return "LlmProxy" + case utils.TypeLLMProvider: + return "LlmProvider" + case utils.TypeLLMProviderTemplate: + return "LlmProviderTemplate" + case utils.TypeMCPProxy: + return "McpProxy" + default: + return "RestApi" + } +} + +// MetadataAPIVersion returns the apiVersion for an artifact's metadata.yaml, +// which differs between the management and ai-workspace planes. +func MetadataAPIVersion(artifactType string) string { + if IsAIWorkspaceType(artifactType) { + return AIWorkspaceAPIVersion + } + return ManagementAPIVersion +} diff --git a/cli/src/internal/project/config.go b/cli/src/internal/project/config.go new file mode 100644 index 0000000000..320675693b --- /dev/null +++ b/cli/src/internal/project/config.go @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package project holds the shared model for an api-platform project on disk: +// the .api-platform/config.yaml schema, the per-portal config layout, and the +// helpers used to scaffold and load them. Keeping these types here lets the +// apiproject, devportal and (future) ai-workspace commands share a single +// source of truth instead of redeclaring the structs in each command package. +package project + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// Default version stamped into a freshly scaffolded project config. +const DefaultConfigVersion = "1.0.0" + +// Default project-level file paths (relative to the project root). The project +// is used for both APIs and non-API artifacts (MCP proxies, LLM providers), +// so the keys deliberately avoid the "api" prefix. +const ( + DefaultDeploymentArtifact = "./runtime.yaml" + DefaultMetadataFile = "./metadata.yaml" + DefaultDefinition = "./definition.yaml" + DefaultDocs = "./docs" + DefaultTests = "./tests" +) + +// FilePaths describes where the core project artifacts live, relative to the +// project root. +type FilePaths struct { + DeploymentArtifact string `yaml:"deploymentArtifact,omitempty"` + MetadataFile string `yaml:"metadataFile,omitempty"` + Definition string `yaml:"definition,omitempty"` + Docs string `yaml:"docs,omitempty"` + Tests string `yaml:"tests,omitempty"` +} + +// PortalFilePaths describes a portal-specific artifact layout. Every portal +// section (devportals, aiWorkspaces) shares the same {name, portalRoot, +// filePaths} layout; only the contents of filePaths are portal specific. +type PortalFilePaths struct { + MetadataFile string `yaml:"metadataFile,omitempty"` + Definition string `yaml:"definition,omitempty"` + Docs string `yaml:"docs,omitempty"` + Content string `yaml:"content,omitempty"` +} + +// PortalConfig is the shared layout for any portal section in the project +// config (devportals, aiWorkspaces, ...). +type PortalConfig struct { + Name string `yaml:"name,omitempty"` + PortalRoot string `yaml:"portalRoot,omitempty"` + FilePaths PortalFilePaths `yaml:"filePaths,omitempty"` +} + +// Config is the on-disk .api-platform/config.yaml for an api-platform project. +type Config struct { + Version string `yaml:"version,omitempty"` + FilePaths FilePaths `yaml:"filePaths,omitempty"` + GovernanceRulesets []string `yaml:"governanceRulesets"` + AutoSync map[string]interface{} `yaml:"autoSync,omitempty"` + DevPortals []PortalConfig `yaml:"devportals,omitempty"` + AIWorkspaces []PortalConfig `yaml:"aiWorkspaces,omitempty"` +} + +// DefaultFilePaths returns the project-level file paths used when scaffolding a +// new project. +func DefaultFilePaths() FilePaths { + return FilePaths{ + DeploymentArtifact: DefaultDeploymentArtifact, + MetadataFile: DefaultMetadataFile, + Definition: DefaultDefinition, + Docs: DefaultDocs, + Tests: DefaultTests, + } +} + +// Normalize fills any empty project-level file path with its default so callers +// can rely on every path being populated after a load. +func (c *Config) Normalize() { + defaults := DefaultFilePaths() + if strings.TrimSpace(c.FilePaths.DeploymentArtifact) == "" { + c.FilePaths.DeploymentArtifact = defaults.DeploymentArtifact + } + if strings.TrimSpace(c.FilePaths.MetadataFile) == "" { + c.FilePaths.MetadataFile = defaults.MetadataFile + } + if strings.TrimSpace(c.FilePaths.Definition) == "" { + c.FilePaths.Definition = defaults.Definition + } + if strings.TrimSpace(c.FilePaths.Docs) == "" { + c.FilePaths.Docs = defaults.Docs + } + if strings.TrimSpace(c.FilePaths.Tests) == "" { + c.FilePaths.Tests = defaults.Tests + } +} + +// Load reads and parses a project config from configPath, normalizing the file +// paths before returning. +func Load(configPath string) (*Config, error) { + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read project config: %w", err) + } + + var config Config + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse project config: %w", err) + } + + config.Normalize() + return &config, nil +} + +// Save marshals config back to configPath. +func Save(configPath string, config *Config) error { + data, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal project config: %w", err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("failed to save project config: %w", err) + } + + return nil +} diff --git a/cli/src/internal/project/scaffold.go b/cli/src/internal/project/scaffold.go new file mode 100644 index 0000000000..788151bd2b --- /dev/null +++ b/cli/src/internal/project/scaffold.go @@ -0,0 +1,300 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package project + +import ( + "bytes" + "fmt" + + "gopkg.in/yaml.v3" +) + +// manifestMeta is the metadata block shared by every generated manifest. +type manifestMeta struct { + Name string `yaml:"name"` +} + +// BusinessInformation captures the ownership metadata carried by management +// (REST) artifacts. +type BusinessInformation struct { + BusinessOwner string `yaml:"businessOwner"` + BusinessOwnerEmail string `yaml:"businessOwnerEmail"` + TechnicalOwner string `yaml:"technicalOwner"` + TechnicalOwnerEmail string `yaml:"technicalOwnerEmail"` +} + +// Endpoints captures the backend endpoints carried by management artifacts. +type Endpoints struct { + SandboxURL string `yaml:"sandboxUrl"` + ProductionURL string `yaml:"productionUrl"` +} + +type manifest struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata manifestMeta `yaml:"metadata"` + Spec interface{} `yaml:"spec"` +} + +// managementMetadataSpec is the metadata.yaml spec for management-plane (REST) +// artifacts. +type managementMetadataSpec struct { + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` + Description string `yaml:"description"` + GatewayType string `yaml:"gatewayType"` + Status string `yaml:"status"` + ReferenceID string `yaml:"referenceID"` + Tags []string `yaml:"tags"` + Labels []string `yaml:"labels"` + BusinessInformation BusinessInformation `yaml:"businessInformation"` + Endpoints Endpoints `yaml:"endpoints"` +} + +// aiWorkspaceMetadataSpec is the slimmer metadata.yaml spec for ai-workspace +// artifacts (LLM proxies/providers, MCP proxies). +type aiWorkspaceMetadataSpec struct { + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` +} + +type runtimeSpec struct { + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` + Context string `yaml:"context"` + Upstream runtimeUpstream `yaml:"upstream"` + Operations []runtimeOperation `yaml:"operations"` +} + +type runtimeUpstream struct { + Main runtimeUpstreamTarget `yaml:"main"` +} + +type runtimeUpstreamTarget struct { + URL string `yaml:"url"` +} + +type runtimeOperation struct { + Path string `yaml:"path"` + Method string `yaml:"method"` +} + +// BuildConfigYAML renders the default .api-platform/config.yaml for a new +// project, sourcing the file-path values from the shared FilePaths struct so +// the scaffold and the loader can never drift apart. +func BuildConfigYAML() (string, error) { + config := Config{ + Version: DefaultConfigVersion, + FilePaths: DefaultFilePaths(), + GovernanceRulesets: []string{}, + AutoSync: map[string]interface{}{ + "gatewayArtifactFromDefinition": true, + }, + } + + return renderYAML(config, map[string]string{ + "filePaths": "Default file paths (can be customized)", + "governanceRulesets": "Governance rulesets for design-time validation", + "autoSync": "Auto-sync configuration for vscode plugin", + }, map[string]string{ + "autoSync.gatewayArtifactFromDefinition": "Auto-generate runtime.yaml when definition.yaml changes", + }) +} + +// BuildMetadataYAML renders the default metadata.yaml for the given artifact +// type. Management (REST) artifacts get the full business/ownership metadata; +// ai-workspace artifacts get the slim displayName/version form. +func BuildMetadataYAML(artifactType, resourceName, displayName string) (string, error) { + m := manifest{ + APIVersion: MetadataAPIVersion(artifactType), + Kind: ArtifactKind(artifactType), + Metadata: manifestMeta{Name: resourceName}, + } + + if IsAIWorkspaceType(artifactType) { + m.Spec = aiWorkspaceMetadataSpec{ + DisplayName: displayName, + Version: "v1.0", + } + } else { + m.Spec = managementMetadataSpec{ + DisplayName: displayName, + Version: "v1.0", + GatewayType: DefaultGatewayType, + Status: "PUBLISHED", + Tags: []string{}, + Labels: []string{}, + } + } + + return renderYAML(m, nil, nil) +} + +// BuildRuntimeYAML renders the default runtime.yaml (the gateway deployment +// artifact) for the given artifact type. +func BuildRuntimeYAML(artifactType, resourceName, displayName string) (string, error) { + m := manifest{ + APIVersion: GatewayAPIVersion, + Kind: ArtifactKind(artifactType), + Metadata: manifestMeta{Name: resourceName}, + Spec: runtimeSpec{ + DisplayName: displayName, + Upstream: runtimeUpstream{ + Main: runtimeUpstreamTarget{URL: "http://sample-backend.org:9080"}, + }, + Operations: []runtimeOperation{ + {Path: "/*", Method: "GET"}, + {Path: "/*", Method: "POST"}, + {Path: "/*", Method: "PUT"}, + {Path: "/*", Method: "DELETE"}, + {Path: "/*", Method: "OPTIONS"}, + }, + }, + } + + return renderYAML(m, nil, map[string]string{ + "spec.upstream.main.url": "Change this to your backend URL", + }) +} + +// BuildDefinitionYAML renders the default OpenAPI definition.yaml. +func BuildDefinitionYAML(displayName string) string { + return fmt.Sprintf(`openapi: 3.0.3 +info: + title: %q + version: v1.0 +servers: + - url: https://example.com +paths: + "/*": + get: + responses: + "200": + description: OK + post: + responses: + "200": + description: OK + put: + responses: + "200": + description: OK + delete: + responses: + "200": + description: OK + options: + responses: + "200": + description: OK +`, displayName) +} + +// renderYAML marshals v to YAML and attaches the supplied head/line comments to +// the addressed keys. Comment keys are dotted paths into the document +// (e.g. "spec.upstream.main.url"). +func renderYAML(v interface{}, headComments, lineComments map[string]string) (string, error) { + var doc yaml.Node + if err := doc.Encode(v); err != nil { + return "", fmt.Errorf("failed to encode manifest: %w", err) + } + + for path, comment := range headComments { + applyComment(&doc, splitPath(path), comment, true) + } + for path, comment := range lineComments { + applyComment(&doc, splitPath(path), comment, false) + } + + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + if err := encoder.Encode(&doc); err != nil { + return "", fmt.Errorf("failed to marshal manifest: %w", err) + } + if err := encoder.Close(); err != nil { + return "", fmt.Errorf("failed to flush manifest: %w", err) + } + return buf.String(), nil +} + +func splitPath(path string) []string { + segments := make([]string, 0) + for _, segment := range splitOnDot(path) { + if segment != "" { + segments = append(segments, segment) + } + } + return segments +} + +func splitOnDot(path string) []string { + var segments []string + current := "" + for _, r := range path { + if r == '.' { + segments = append(segments, current) + current = "" + continue + } + current += string(r) + } + return append(segments, current) +} + +// applyComment walks the mapping nodes of doc following path and sets a head or +// line comment on the addressed key. Missing keys are ignored so callers can +// describe optional fields without guarding each one. +func applyComment(doc *yaml.Node, path []string, comment string, head bool) { + if len(path) == 0 { + return + } + + node := doc + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + node = node.Content[0] + } + + for depth, key := range path { + if node.Kind != yaml.MappingNode { + return + } + matched := false + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode := node.Content[i] + if keyNode.Value != key { + continue + } + if depth == len(path)-1 { + if head { + keyNode.HeadComment = comment + } else { + keyNode.LineComment = comment + } + return + } + node = node.Content[i+1] + matched = true + break + } + if !matched { + return + } + } +} diff --git a/cli/src/utils/constants.go b/cli/src/utils/constants.go index 7e475ae7f5..ee942cfa63 100644 --- a/cli/src/utils/constants.go +++ b/cli/src/utils/constants.go @@ -84,9 +84,14 @@ const ( MaxUncompressedPerFile = 20 * 1024 * 1024 // Maximum uncompressed size allowed per file (20 MB). MaxTotalUncompressed = 100 * 1024 * 1024 // Maximum total uncompressed size allowed for the archive (100 MB). - // API Types - APITypeREST = "rest" - APITypeSOAP = "soap" + // Artifact Types + TypeREST = "rest" + TypeSOAP = "soap" + TypeLLMProxy = "llm-proxy" + TypeLLMProvider = "llm-provider" + TypeLLMProviderTemplate = "llm-provider-template" + TypeMCPProxy = "mcp-proxy" + ) // PolicyHub REST API defaults and paths diff --git a/cli/src/utils/flags.go b/cli/src/utils/flags.go index c7ce8e348a..d0d7c9c93c 100644 --- a/cli/src/utils/flags.go +++ b/cli/src/utils/flags.go @@ -78,6 +78,8 @@ const ( FlagSubscriptionToken = "subscription-token" FlagBillingCustomerID = "billing-customer-id" FlagBillingSubscriptionID = "billing-subscription-id" + FlagReferenceID = "reference-id" + FlagGatewayType = "gateway-type" ) var shortFlags = map[string]string{ From e23a34da1d9a763ff0bd0856a346f2d7e64e25e8 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Wed, 17 Jun 2026 10:16:32 +0530 Subject: [PATCH 03/27] rename apiproject to project --- cli/src/cmd/{apiproject => project}/init.go | 18 ++++++++--------- .../cmd/{apiproject => project}/init_test.go | 2 +- cli/src/cmd/{apiproject => project}/root.go | 20 +++++++++---------- cli/src/cmd/root.go | 4 ++-- cli/src/internal/project/config.go | 2 +- 5 files changed, 23 insertions(+), 23 deletions(-) rename cli/src/cmd/{apiproject => project}/init.go (92%) rename cli/src/cmd/{apiproject => project}/init_test.go (99%) rename cli/src/cmd/{apiproject => project}/root.go (64%) diff --git a/cli/src/cmd/apiproject/init.go b/cli/src/cmd/project/init.go similarity index 92% rename from cli/src/cmd/apiproject/init.go rename to cli/src/cmd/project/init.go index 79aa682102..5ff57f4df0 100644 --- a/cli/src/cmd/apiproject/init.go +++ b/cli/src/cmd/project/init.go @@ -15,7 +15,7 @@ * specific language governing permissions and limitations * under the License. */ -package apiproject +package project import ( "fmt" @@ -31,11 +31,11 @@ import ( const ( InitCmdLiteral = "init" - InitCmdExample = `# Initialize a new API project -ap apiproject init --display-name foo-api --type rest + InitCmdExample = `# Initialize a new project +ap project init --display-name foo-api --type rest -# Add a API project fully interactively cobra -ap apiproject init` +# Initialize a new project interactively +ap project init` ) var displayName string @@ -44,8 +44,8 @@ var addNoInteractive bool var initCmd = &cobra.Command{ Use: InitCmdLiteral, - Short: "Initialize a new API project", - Long: "Initialize a new API project with the specified parameters.", + Short: "Initialize a new project", + Long: "Initialize a new project with the specified parameters.", Example: InitCmdExample, Run: func(cmd *cobra.Command, args []string) { if err := runInitCommand(); err != nil { @@ -65,7 +65,7 @@ func runInitCommand() error { var err error if !addNoInteractive { if strings.TrimSpace(displayName) == "" { - displayName, err = utils.PromptInput("Enter API Project name: ") + displayName, err = utils.PromptInput("Enter project name: ") if err != nil { return fmt.Errorf("Failed to read display name: %w", err) } @@ -96,7 +96,7 @@ func runInitCommand() error { return err } - fmt.Printf("Artifact project created at .%c%s\n", os.PathSeparator, displayName) + fmt.Printf("Project created at .%c%s\n", os.PathSeparator, displayName) return nil } diff --git a/cli/src/cmd/apiproject/init_test.go b/cli/src/cmd/project/init_test.go similarity index 99% rename from cli/src/cmd/apiproject/init_test.go rename to cli/src/cmd/project/init_test.go index c4e0298779..5d54b8b59e 100644 --- a/cli/src/cmd/apiproject/init_test.go +++ b/cli/src/cmd/project/init_test.go @@ -15,7 +15,7 @@ * specific language governing permissions and limitations * under the License. */ -package apiproject +package project import ( "os" diff --git a/cli/src/cmd/apiproject/root.go b/cli/src/cmd/project/root.go similarity index 64% rename from cli/src/cmd/apiproject/root.go rename to cli/src/cmd/project/root.go index fbaf768e29..c9e1264f0c 100644 --- a/cli/src/cmd/apiproject/root.go +++ b/cli/src/cmd/project/root.go @@ -15,26 +15,26 @@ * specific language governing permissions and limitations * under the License. */ -package apiproject +package project import "github.com/spf13/cobra" const ( - ApiProjectCmdLiteral = "apiproject" - ApiProjectCmdExample = `# Add a new API project -ap apiproject init --display-name foo-api --type rest --version 1.0 --context /foo` + ProjectCmdLiteral = "project" + ProjectCmdExample = `# Initialize a new project +ap project init --display-name foo-api --type rest` ) -var ApiProjectCmd = &cobra.Command{ - Use: ApiProjectCmdLiteral, - Short: "Execute API project operations", - Long: "This command allows you to manage API projects.", - Example: ApiProjectCmdExample, +var ProjectCmd = &cobra.Command{ + Use: ProjectCmdLiteral, + Short: "Execute project operations", + Long: "This command allows you to manage projects (REST/SOAP/GraphQL APIs, LLM proxies/providers, MCP proxies).", + Example: ProjectCmdExample, Run: func(cmd *cobra.Command, args []string) { cmd.Help() }, } func init() { - ApiProjectCmd.AddCommand(initCmd) + ProjectCmd.AddCommand(initCmd) } \ No newline at end of file diff --git a/cli/src/cmd/root.go b/cli/src/cmd/root.go index 68fa1ed94a..22bf285517 100644 --- a/cli/src/cmd/root.go +++ b/cli/src/cmd/root.go @@ -26,7 +26,7 @@ import ( "github.com/wso2/api-platform/cli/cmd/devportal" "github.com/wso2/api-platform/cli/cmd/gateway" "github.com/wso2/api-platform/cli/cmd/platform" - "github.com/wso2/api-platform/cli/cmd/apiproject" + "github.com/wso2/api-platform/cli/cmd/project" "github.com/wso2/api-platform/cli/utils" ) @@ -70,7 +70,7 @@ func init() { rootCmd.AddCommand(devportal.DevPortalCmd) rootCmd.AddCommand(gateway.GatewayCmd) rootCmd.AddCommand(platform.PlatformCmd) - rootCmd.AddCommand(apiproject.ApiProjectCmd) + rootCmd.AddCommand(project.ProjectCmd) rootCmd.AddCommand(versionCmd) } diff --git a/cli/src/internal/project/config.go b/cli/src/internal/project/config.go index 320675693b..1a3ab38b2e 100644 --- a/cli/src/internal/project/config.go +++ b/cli/src/internal/project/config.go @@ -19,7 +19,7 @@ // Package project holds the shared model for an api-platform project on disk: // the .api-platform/config.yaml schema, the per-portal config layout, and the // helpers used to scaffold and load them. Keeping these types here lets the -// apiproject, devportal and (future) ai-workspace commands share a single +// project, devportal and (future) ai-workspace commands share a single // source of truth instead of redeclaring the structs in each command package. package project From a9a5bb176b86ccf22fdd22fbba7f00eb68743e79 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Mon, 15 Jun 2026 10:47:55 +0530 Subject: [PATCH 04/27] update devportal cli with updated openapi spec --- cli/src/cmd/devportal/apikey/commands_test.go | 8 ++++---- cli/src/cmd/devportal/apikey/generate.go | 3 +-- cli/src/cmd/devportal/apikey/get.go | 2 +- cli/src/cmd/devportal/apikey/regenerate.go | 2 +- cli/src/cmd/devportal/apikey/revoke.go | 2 +- .../devportal/application/commands_test.go | 10 +++++----- cli/src/cmd/devportal/application/create.go | 3 +-- cli/src/cmd/devportal/application/delete.go | 2 +- cli/src/cmd/devportal/application/get.go | 2 +- cli/src/cmd/devportal/application/update.go | 2 +- cli/src/cmd/devportal/org/add.go | 2 +- cli/src/cmd/devportal/org/commands_test.go | 10 +++++----- cli/src/cmd/devportal/org/delete.go | 2 +- cli/src/cmd/devportal/org/edit.go | 2 +- cli/src/cmd/devportal/org/get.go | 2 +- cli/src/cmd/devportal/org/list.go | 2 +- .../cmd/devportal/restapi/commands_test.go | 10 +++++----- cli/src/cmd/devportal/restapi/delete.go | 2 +- cli/src/cmd/devportal/restapi/edit.go | 2 +- cli/src/cmd/devportal/restapi/get.go | 2 +- cli/src/cmd/devportal/restapi/list.go | 3 +-- cli/src/cmd/devportal/restapi/publish.go | 3 +-- .../cmd/devportal/subplan/commands_test.go | 8 ++++---- cli/src/cmd/devportal/subplan/delete.go | 2 +- cli/src/cmd/devportal/subplan/get.go | 2 +- cli/src/cmd/devportal/subplan/publish.go | 3 +-- .../devportal/subscription/commands_test.go | 10 +++++----- cli/src/cmd/devportal/subscription/create.go | 3 +-- cli/src/cmd/devportal/subscription/delete.go | 2 +- cli/src/cmd/devportal/subscription/edit.go | 2 +- cli/src/cmd/devportal/subscription/get.go | 2 +- cli/src/internal/devportal/helpers.go | 20 +++++++++++++++++++ 32 files changed, 73 insertions(+), 59 deletions(-) diff --git a/cli/src/cmd/devportal/apikey/commands_test.go b/cli/src/cmd/devportal/apikey/commands_test.go index b08eb546df..e8d7238dbe 100644 --- a/cli/src/cmd/devportal/apikey/commands_test.go +++ b/cli/src/cmd/devportal/apikey/commands_test.go @@ -36,7 +36,7 @@ func TestRunGenerateCommand_SendsPayload(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/api-keys/generate" { + if req.URL.Path != "/o/org-1/devportal/v1/api-keys/generate" { t.Fatalf("unexpected request path %s", req.URL.Path) } body, err := io.ReadAll(req.Body) @@ -75,7 +75,7 @@ func TestRunGetCommand_ListsAPIKeys(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/api-keys" { + if req.URL.Path != "/o/org-1/devportal/v1/api-keys" { t.Fatalf("unexpected request path %s", req.URL.Path) } if got := req.URL.Query().Get("apiId"); got != "api-1" { @@ -105,7 +105,7 @@ func TestRunRegenerateCommand_PostsToRegenerate(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/api-keys/key-1/regenerate" { + if req.URL.Path != "/o/org-1/devportal/v1/api-keys/key-1/regenerate" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") @@ -132,7 +132,7 @@ func TestRunRevokeCommand_PostsToRevoke(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/api-keys/key-1/revoke" { + if req.URL.Path != "/o/org-1/devportal/v1/api-keys/key-1/revoke" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.WriteHeader(http.StatusNoContent) diff --git a/cli/src/cmd/devportal/apikey/generate.go b/cli/src/cmd/devportal/apikey/generate.go index a0ff46f48e..39261e7e8a 100644 --- a/cli/src/cmd/devportal/apikey/generate.go +++ b/cli/src/cmd/devportal/apikey/generate.go @@ -22,7 +22,6 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" "os" "regexp" "strings" @@ -123,7 +122,7 @@ func runGenerateCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, generateInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/api-keys/generate", url.PathEscape(orgID)) + path := internaldevportal.OrgScopedPath(orgID, "api-keys/generate") resp, err := client.PostJSON(path, payload) if err != nil { return internaldevportal.WrapRequestError("generate API key", err, generateInsecure) diff --git a/cli/src/cmd/devportal/apikey/get.go b/cli/src/cmd/devportal/apikey/get.go index 166996e539..b59ab9de67 100644 --- a/cli/src/cmd/devportal/apikey/get.go +++ b/cli/src/cmd/devportal/apikey/get.go @@ -93,7 +93,7 @@ func runGetCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, getInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/api-keys?apiId=%s", url.PathEscape(orgID), url.QueryEscape(apiID)) + path := internaldevportal.OrgScopedPath(orgID, "api-keys?apiId="+url.QueryEscape(apiID)) resp, err := client.Get(path) if err != nil { return internaldevportal.WrapRequestError("get API keys", err, getInsecure) diff --git a/cli/src/cmd/devportal/apikey/regenerate.go b/cli/src/cmd/devportal/apikey/regenerate.go index 7bba2b096d..f52c0441f4 100644 --- a/cli/src/cmd/devportal/apikey/regenerate.go +++ b/cli/src/cmd/devportal/apikey/regenerate.go @@ -95,7 +95,7 @@ func runRegenerateCommand() error { client := internaldevportal.NewClientWithOptions(devPortal, regenerateInsecure) baseURL := strings.TrimSuffix(devPortal.URL, "/") - path := fmt.Sprintf("/devportal/organizations/%s/api-keys/%s/regenerate", url.PathEscape(orgID), url.PathEscape(apiKeyID)) + path := internaldevportal.OrgScopedPath(orgID, "api-keys/"+url.PathEscape(apiKeyID)+"/regenerate") req, err := http.NewRequest(http.MethodPost, baseURL+path, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) diff --git a/cli/src/cmd/devportal/apikey/revoke.go b/cli/src/cmd/devportal/apikey/revoke.go index dee00159ca..ae6a22a6d9 100644 --- a/cli/src/cmd/devportal/apikey/revoke.go +++ b/cli/src/cmd/devportal/apikey/revoke.go @@ -95,7 +95,7 @@ func runRevokeCommand() error { client := internaldevportal.NewClientWithOptions(devPortal, revokeInsecure) baseURL := strings.TrimSuffix(devPortal.URL, "/") - path := fmt.Sprintf("/devportal/organizations/%s/api-keys/%s/revoke", url.PathEscape(orgID), url.PathEscape(apiKeyID)) + path := internaldevportal.OrgScopedPath(orgID, "api-keys/"+url.PathEscape(apiKeyID)+"/revoke") req, err := http.NewRequest(http.MethodPost, baseURL+path, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) diff --git a/cli/src/cmd/devportal/application/commands_test.go b/cli/src/cmd/devportal/application/commands_test.go index 9217ca0727..3e428757cb 100644 --- a/cli/src/cmd/devportal/application/commands_test.go +++ b/cli/src/cmd/devportal/application/commands_test.go @@ -37,7 +37,7 @@ func TestRunCreateCommand_SendsJSONPayload(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/applications" { + if req.URL.Path != "/o/org-1/devportal/v1/applications" { t.Fatalf("unexpected request path %s", req.URL.Path) } body, err := io.ReadAll(req.Body) @@ -109,7 +109,7 @@ func TestRunUpdateCommand_SendsJSONPayload(t *testing.T) { if req.Method != http.MethodPut { t.Fatalf("expected PUT request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/applications/app-1" { + if req.URL.Path != "/o/org-1/devportal/v1/applications/app-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } body, err := io.ReadAll(req.Body) @@ -145,13 +145,13 @@ func TestRunGetCommand_ListAllAndSingle(t *testing.T) { server := testutil.NewDevPortalServer(t, func(w http.ResponseWriter, req *http.Request) { switch req.URL.Path { - case "/devportal/organizations/org-1/applications": + case "/o/org-1/devportal/v1/applications": if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[{"applicationId":"app-1"}]`)) - case "/devportal/organizations/org-1/applications/app-1": + case "/o/org-1/devportal/v1/applications/app-1": if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } @@ -197,7 +197,7 @@ func TestRunDeleteCommand_SendsDelete(t *testing.T) { if req.Method != http.MethodDelete { t.Fatalf("expected DELETE request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/applications/app-1" { + if req.URL.Path != "/o/org-1/devportal/v1/applications/app-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") diff --git a/cli/src/cmd/devportal/application/create.go b/cli/src/cmd/devportal/application/create.go index c8620ac09e..f8fdd4127c 100644 --- a/cli/src/cmd/devportal/application/create.go +++ b/cli/src/cmd/devportal/application/create.go @@ -22,7 +22,6 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" "os" "strings" @@ -110,7 +109,7 @@ func runCreateCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, createInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/applications", url.PathEscape(orgID)) + path := internaldevportal.OrgScopedPath(orgID, "applications") resp, err := client.PostJSON(path, payload) if err != nil { return internaldevportal.WrapRequestError("create application", err, createInsecure) diff --git a/cli/src/cmd/devportal/application/delete.go b/cli/src/cmd/devportal/application/delete.go index 15b75f90ce..cdc196bedb 100644 --- a/cli/src/cmd/devportal/application/delete.go +++ b/cli/src/cmd/devportal/application/delete.go @@ -93,7 +93,7 @@ func runDeleteCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, deleteInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/applications/%s", url.PathEscape(orgID), url.PathEscape(appID)) + path := internaldevportal.OrgScopedPath(orgID, "applications/"+url.PathEscape(appID)) resp, err := client.Delete(path) if err != nil { return internaldevportal.WrapRequestError("delete application", err, deleteInsecure) diff --git a/cli/src/cmd/devportal/application/get.go b/cli/src/cmd/devportal/application/get.go index 82f84ed7f7..dcbdc349b9 100644 --- a/cli/src/cmd/devportal/application/get.go +++ b/cli/src/cmd/devportal/application/get.go @@ -90,7 +90,7 @@ func runGetCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, getInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/applications", url.PathEscape(orgID)) + path := internaldevportal.OrgScopedPath(orgID, "applications") appID := strings.TrimSpace(getAppID) if appID != "" { path = fmt.Sprintf("%s/%s", path, url.PathEscape(appID)) diff --git a/cli/src/cmd/devportal/application/update.go b/cli/src/cmd/devportal/application/update.go index 139164c2e8..6a48be94b1 100644 --- a/cli/src/cmd/devportal/application/update.go +++ b/cli/src/cmd/devportal/application/update.go @@ -110,7 +110,7 @@ func runUpdateCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, updateInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/applications/%s", url.PathEscape(orgID), url.PathEscape(appID)) + path := internaldevportal.OrgScopedPath(orgID, "applications/"+url.PathEscape(appID)) resp, err := client.PutJSON(path, payload) if err != nil { return internaldevportal.WrapRequestError("update application", err, updateInsecure) diff --git a/cli/src/cmd/devportal/org/add.go b/cli/src/cmd/devportal/org/add.go index 1159b91015..66fc3af2c5 100644 --- a/cli/src/cmd/devportal/org/add.go +++ b/cli/src/cmd/devportal/org/add.go @@ -84,7 +84,7 @@ func runAddCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, addInsecure) - resp, err := client.PostMultipartFile("/devportal/organizations", "organization", organizationPath) + resp, err := client.PostMultipartFile("/organizations", "organization", organizationPath) if err != nil { return internaldevportal.WrapRequestError("create organization", err, addInsecure) } diff --git a/cli/src/cmd/devportal/org/commands_test.go b/cli/src/cmd/devportal/org/commands_test.go index ac7dc49a52..5c6cafcb9f 100644 --- a/cli/src/cmd/devportal/org/commands_test.go +++ b/cli/src/cmd/devportal/org/commands_test.go @@ -37,7 +37,7 @@ func TestRunListCommand_PrintsOrganizationTable(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations" { + if req.URL.Path != "/organizations" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") @@ -70,7 +70,7 @@ func TestRunGetCommand_PrintsJSON(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1" { + if req.URL.Path != "/organizations/org-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") @@ -101,7 +101,7 @@ func TestRunAddCommand_SendsOrganizationYAMLMultipart(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations" { + if req.URL.Path != "/organizations" { t.Fatalf("unexpected request path %s", req.URL.Path) } assertMultipartOrganization(t, req, "org.yaml", "apiVersion: devportal.api-platform.wso2.com/v1\nkind: Organization\n") @@ -170,7 +170,7 @@ func TestRunEditCommand_SendsJSONPayload(t *testing.T) { if req.Method != http.MethodPut { t.Fatalf("expected PUT request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1" { + if req.URL.Path != "/organizations/org-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } body, err := io.ReadAll(req.Body) @@ -228,7 +228,7 @@ func TestRunDeleteCommand_SendsDelete(t *testing.T) { if req.Method != http.MethodDelete { t.Fatalf("expected DELETE request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1" { + if req.URL.Path != "/organizations/org-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") diff --git a/cli/src/cmd/devportal/org/delete.go b/cli/src/cmd/devportal/org/delete.go index a1bfb13040..cf461b2b27 100644 --- a/cli/src/cmd/devportal/org/delete.go +++ b/cli/src/cmd/devportal/org/delete.go @@ -84,7 +84,7 @@ func runDeleteCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, deleteInsecure) - path := fmt.Sprintf("/devportal/organizations/%s", url.PathEscape(orgID)) + path := fmt.Sprintf("/organizations/%s", url.PathEscape(orgID)) resp, err := client.Delete(path) if err != nil { return internaldevportal.WrapRequestError("delete organization", err, deleteInsecure) diff --git a/cli/src/cmd/devportal/org/edit.go b/cli/src/cmd/devportal/org/edit.go index 3356a775c6..9ee390adb6 100644 --- a/cli/src/cmd/devportal/org/edit.go +++ b/cli/src/cmd/devportal/org/edit.go @@ -92,7 +92,7 @@ func runEditCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, editInsecure) - path := fmt.Sprintf("/devportal/organizations/%s", url.PathEscape(orgID)) + path := fmt.Sprintf("/organizations/%s", url.PathEscape(orgID)) resp, err := client.PutJSON(path, payload) if err != nil { return internaldevportal.WrapRequestError("update organization", err, editInsecure) diff --git a/cli/src/cmd/devportal/org/get.go b/cli/src/cmd/devportal/org/get.go index 143f020d52..996a841948 100644 --- a/cli/src/cmd/devportal/org/get.go +++ b/cli/src/cmd/devportal/org/get.go @@ -84,7 +84,7 @@ func runGetCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, getInsecure) - path := fmt.Sprintf("/devportal/organizations/%s", url.PathEscape(orgID)) + path := fmt.Sprintf("/organizations/%s", url.PathEscape(orgID)) resp, err := client.Get(path) if err != nil { return internaldevportal.WrapRequestError("get organization", err, getInsecure) diff --git a/cli/src/cmd/devportal/org/list.go b/cli/src/cmd/devportal/org/list.go index 7a21712a7d..db1c965a06 100644 --- a/cli/src/cmd/devportal/org/list.go +++ b/cli/src/cmd/devportal/org/list.go @@ -84,7 +84,7 @@ func runListCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, listInsecure) - resp, err := client.Get("/devportal/organizations") + resp, err := client.Get("/organizations") if err != nil { return internaldevportal.WrapRequestError("list organizations", err, listInsecure) } diff --git a/cli/src/cmd/devportal/restapi/commands_test.go b/cli/src/cmd/devportal/restapi/commands_test.go index 1f061facfe..1eb5ea41b9 100644 --- a/cli/src/cmd/devportal/restapi/commands_test.go +++ b/cli/src/cmd/devportal/restapi/commands_test.go @@ -38,7 +38,7 @@ func TestRunListCommand_PrintsTable(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/apis" { + if req.URL.Path != "/o/org-1/devportal/v1/apis" { t.Fatalf("unexpected request path %s", req.URL.Path) } if req.URL.RawQuery != "tags=default" { @@ -138,7 +138,7 @@ func TestRunGetCommand_PrintsJSON(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/apis/api-1" { + if req.URL.Path != "/o/org-1/devportal/v1/apis/api-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") @@ -174,7 +174,7 @@ func TestRunPublishCommand_DefaultArtifactAndMultipartUpload(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/apis" { + if req.URL.Path != "/o/org-1/devportal/v1/apis" { t.Fatalf("unexpected request path %s", req.URL.Path) } assertMultipartArtifact(t, req, "devportal.zip") @@ -227,7 +227,7 @@ func TestRunEditCommand_UploadsArtifact(t *testing.T) { if req.Method != http.MethodPut { t.Fatalf("expected PUT request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/apis/api-1" { + if req.URL.Path != "/o/org-1/devportal/v1/apis/api-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } assertMultipartArtifact(t, req, "artifact.zip") @@ -254,7 +254,7 @@ func TestRunDeleteCommand_SendsDelete(t *testing.T) { if req.Method != http.MethodDelete { t.Fatalf("expected DELETE request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/apis/api-1" { + if req.URL.Path != "/o/org-1/devportal/v1/apis/api-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") diff --git a/cli/src/cmd/devportal/restapi/delete.go b/cli/src/cmd/devportal/restapi/delete.go index f1dadd65c9..c8dfe01a10 100644 --- a/cli/src/cmd/devportal/restapi/delete.go +++ b/cli/src/cmd/devportal/restapi/delete.go @@ -92,7 +92,7 @@ func runDeleteCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, deleteInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/apis/%s", url.PathEscape(orgID), url.PathEscape(apiID)) + path := internaldevportal.OrgScopedPath(orgID, "apis/"+url.PathEscape(apiID)) resp, err := client.Delete(path) if err != nil { return internaldevportal.WrapRequestError("delete api artifact", err, deleteInsecure) diff --git a/cli/src/cmd/devportal/restapi/edit.go b/cli/src/cmd/devportal/restapi/edit.go index 11e89d576f..e1017fb0b8 100644 --- a/cli/src/cmd/devportal/restapi/edit.go +++ b/cli/src/cmd/devportal/restapi/edit.go @@ -102,7 +102,7 @@ func runEditCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, editInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/apis/%s", url.PathEscape(orgID), url.PathEscape(apiID)) + path := internaldevportal.OrgScopedPath(orgID, "apis/"+url.PathEscape(apiID)) resp, err := client.PutMultipartFile(path, "artifact", artifactPath) if err != nil { return internaldevportal.WrapRequestError("edit api artifact", err, editInsecure) diff --git a/cli/src/cmd/devportal/restapi/get.go b/cli/src/cmd/devportal/restapi/get.go index c18f51bec5..f5b98c731e 100644 --- a/cli/src/cmd/devportal/restapi/get.go +++ b/cli/src/cmd/devportal/restapi/get.go @@ -92,7 +92,7 @@ func runGetCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, getInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/apis/%s", url.PathEscape(orgID), url.PathEscape(apiID)) + path := internaldevportal.OrgScopedPath(orgID, "apis/"+url.PathEscape(apiID)) resp, err := client.Get(path) if err != nil { return internaldevportal.WrapRequestError("get api artifact", err, getInsecure) diff --git a/cli/src/cmd/devportal/restapi/list.go b/cli/src/cmd/devportal/restapi/list.go index cf15afdb51..f33bd8650f 100644 --- a/cli/src/cmd/devportal/restapi/list.go +++ b/cli/src/cmd/devportal/restapi/list.go @@ -23,7 +23,6 @@ import ( "fmt" "io" "net/http" - "net/url" "os" "strings" @@ -96,7 +95,7 @@ func runListCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, listInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/apis?tags=default", url.PathEscape(orgID)) + path := internaldevportal.OrgScopedPath(orgID, "apis?tags=default") resp, err := client.Get(path) if err != nil { return internaldevportal.WrapRequestError("list api artifacts", err, listInsecure) diff --git a/cli/src/cmd/devportal/restapi/publish.go b/cli/src/cmd/devportal/restapi/publish.go index ad66746f52..82d4a4bb26 100644 --- a/cli/src/cmd/devportal/restapi/publish.go +++ b/cli/src/cmd/devportal/restapi/publish.go @@ -20,7 +20,6 @@ package restapi import ( "fmt" - "net/url" "os" "strings" @@ -96,7 +95,7 @@ func runPublishCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, publishInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/apis", url.PathEscape(orgID)) + path := internaldevportal.OrgScopedPath(orgID, "apis") resp, err := client.PostMultipartFile(path, "artifact", artifactPath) if err != nil { return internaldevportal.WrapRequestError("publish api artifact", err, publishInsecure) diff --git a/cli/src/cmd/devportal/subplan/commands_test.go b/cli/src/cmd/devportal/subplan/commands_test.go index 52d92149fc..a9193cba4f 100644 --- a/cli/src/cmd/devportal/subplan/commands_test.go +++ b/cli/src/cmd/devportal/subplan/commands_test.go @@ -70,7 +70,7 @@ func TestRunPublishCommand_UploadsSinglePlanMultipart(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/subscription-policies" { + if req.URL.Path != "/o/org-1/devportal/v1/subscription-policies" { t.Fatalf("unexpected request path %s", req.URL.Path) } assertMultipartPlan(t, req, "plan.yaml", singlePlanYAML) @@ -100,7 +100,7 @@ func TestRunPublishCommand_UploadsPlanListMultipart(t *testing.T) { testutil.WithTempHome(t) server := testutil.NewDevPortalServer(t, func(w http.ResponseWriter, req *http.Request) { - if req.URL.Path != "/devportal/organizations/org-1/subscription-policies" { + if req.URL.Path != "/o/org-1/devportal/v1/subscription-policies" { t.Fatalf("unexpected request path %s", req.URL.Path) } assertMultipartPlan(t, req, "plans.yaml", planListYAML) @@ -177,7 +177,7 @@ func TestRunGetCommand_GetsPlanByPolicyID(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/subscription-policies/plan-1" { + if req.URL.Path != "/o/org-1/devportal/v1/subscription-policies/plan-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") @@ -215,7 +215,7 @@ func TestRunDeleteCommand_DeletesPlanByPolicyID(t *testing.T) { if req.Method != http.MethodDelete { t.Fatalf("expected DELETE request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/subscription-policies/plan-1" { + if req.URL.Path != "/o/org-1/devportal/v1/subscription-policies/plan-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.WriteHeader(http.StatusNoContent) diff --git a/cli/src/cmd/devportal/subplan/delete.go b/cli/src/cmd/devportal/subplan/delete.go index d72810fcb6..841145c03e 100644 --- a/cli/src/cmd/devportal/subplan/delete.go +++ b/cli/src/cmd/devportal/subplan/delete.go @@ -93,7 +93,7 @@ func runDeleteCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, deleteInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/subscription-policies/%s", url.PathEscape(orgID), url.PathEscape(policyID)) + path := internaldevportal.OrgScopedPath(orgID, "subscription-policies/"+url.PathEscape(policyID)) resp, err := client.Delete(path) if err != nil { return internaldevportal.WrapRequestError("delete subscription plan", err, deleteInsecure) diff --git a/cli/src/cmd/devportal/subplan/get.go b/cli/src/cmd/devportal/subplan/get.go index f76b4efa0d..bf46ae2b20 100644 --- a/cli/src/cmd/devportal/subplan/get.go +++ b/cli/src/cmd/devportal/subplan/get.go @@ -93,7 +93,7 @@ func runGetCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, getInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/subscription-policies/%s", url.PathEscape(orgID), url.PathEscape(policyID)) + path := internaldevportal.OrgScopedPath(orgID, "subscription-policies/"+url.PathEscape(policyID)) resp, err := client.Get(path) if err != nil { return internaldevportal.WrapRequestError("get subscription plan", err, getInsecure) diff --git a/cli/src/cmd/devportal/subplan/publish.go b/cli/src/cmd/devportal/subplan/publish.go index 4a7393b004..1194b0629f 100644 --- a/cli/src/cmd/devportal/subplan/publish.go +++ b/cli/src/cmd/devportal/subplan/publish.go @@ -20,7 +20,6 @@ package subplan import ( "fmt" - "net/url" "os" "strings" @@ -138,7 +137,7 @@ func runPublishCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, publishInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/subscription-policies", url.PathEscape(orgID)) + path := internaldevportal.OrgScopedPath(orgID, "subscription-policies") resp, err := client.PostMultipartFile(path, multipartFieldName, filePath) if err != nil { return internaldevportal.WrapRequestError("publish subscription plan", err, publishInsecure) diff --git a/cli/src/cmd/devportal/subscription/commands_test.go b/cli/src/cmd/devportal/subscription/commands_test.go index 11148e62d4..4d691537a0 100644 --- a/cli/src/cmd/devportal/subscription/commands_test.go +++ b/cli/src/cmd/devportal/subscription/commands_test.go @@ -36,7 +36,7 @@ func TestRunCreateCommand_SendsJSONPayload(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/subscriptions" { + if req.URL.Path != "/o/org-1/devportal/v1/subscriptions" { t.Fatalf("unexpected request path %s", req.URL.Path) } body, err := io.ReadAll(req.Body) @@ -74,7 +74,7 @@ func TestRunEditCommand_SendsJSONPayload(t *testing.T) { if req.Method != http.MethodPut { t.Fatalf("expected PUT request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/subscriptions/sub-1" { + if req.URL.Path != "/o/org-1/devportal/v1/subscriptions/sub-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } body, err := io.ReadAll(req.Body) @@ -108,13 +108,13 @@ func TestRunGetCommand_ListAllAndSingle(t *testing.T) { server := testutil.NewDevPortalServer(t, func(w http.ResponseWriter, req *http.Request) { switch req.URL.Path { - case "/devportal/organizations/org-1/subscriptions": + case "/o/org-1/devportal/v1/subscriptions": if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[{"subscriptionId":"sub-1"}]`)) - case "/devportal/organizations/org-1/subscriptions/sub-1": + case "/o/org-1/devportal/v1/subscriptions/sub-1": if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } @@ -160,7 +160,7 @@ func TestRunDeleteCommand_SendsDelete(t *testing.T) { if req.Method != http.MethodDelete { t.Fatalf("expected DELETE request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/subscriptions/sub-1" { + if req.URL.Path != "/o/org-1/devportal/v1/subscriptions/sub-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") diff --git a/cli/src/cmd/devportal/subscription/create.go b/cli/src/cmd/devportal/subscription/create.go index 3e0ac06dcb..cadf544891 100644 --- a/cli/src/cmd/devportal/subscription/create.go +++ b/cli/src/cmd/devportal/subscription/create.go @@ -22,7 +22,6 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" "os" "strings" @@ -96,7 +95,7 @@ func runCreateCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, createInsecure) - resp, err := client.PostJSON(fmt.Sprintf("/devportal/organizations/%s/subscriptions", url.PathEscape(orgID)), payload) + resp, err := client.PostJSON(internaldevportal.OrgScopedPath(orgID, "subscriptions"), payload) if err != nil { return internaldevportal.WrapRequestError("create platform subscription", err, createInsecure) } diff --git a/cli/src/cmd/devportal/subscription/delete.go b/cli/src/cmd/devportal/subscription/delete.go index 35fc450854..b8e4e6a83f 100644 --- a/cli/src/cmd/devportal/subscription/delete.go +++ b/cli/src/cmd/devportal/subscription/delete.go @@ -93,7 +93,7 @@ func runDeleteCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, deleteInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/subscriptions/%s", url.PathEscape(orgID), url.PathEscape(subscriptionID)) + path := internaldevportal.OrgScopedPath(orgID, "subscriptions/"+url.PathEscape(subscriptionID)) resp, err := client.Delete(path) if err != nil { return internaldevportal.WrapRequestError("delete platform subscription", err, deleteInsecure) diff --git a/cli/src/cmd/devportal/subscription/edit.go b/cli/src/cmd/devportal/subscription/edit.go index 8fdd412319..58ca831624 100644 --- a/cli/src/cmd/devportal/subscription/edit.go +++ b/cli/src/cmd/devportal/subscription/edit.go @@ -97,7 +97,7 @@ func runEditCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, editInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/subscriptions/%s", url.PathEscape(editOrgID), url.PathEscape(subscriptionID)) + path := internaldevportal.OrgScopedPath(editOrgID, "subscriptions/"+url.PathEscape(subscriptionID)) resp, err := client.PutJSON(path, payload) if err != nil { return internaldevportal.WrapRequestError("update platform subscription", err, editInsecure) diff --git a/cli/src/cmd/devportal/subscription/get.go b/cli/src/cmd/devportal/subscription/get.go index c3923d99e3..18ef90fee3 100644 --- a/cli/src/cmd/devportal/subscription/get.go +++ b/cli/src/cmd/devportal/subscription/get.go @@ -87,7 +87,7 @@ func runGetCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, getInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/subscriptions", url.PathEscape(orgID)) + path := internaldevportal.OrgScopedPath(orgID, "subscriptions") if subscriptionID := strings.TrimSpace(getSubscription); subscriptionID != "" { path = fmt.Sprintf("%s/%s", path, url.PathEscape(subscriptionID)) } diff --git a/cli/src/internal/devportal/helpers.go b/cli/src/internal/devportal/helpers.go index 23fda11c2a..b920fcb8b3 100644 --- a/cli/src/internal/devportal/helpers.go +++ b/cli/src/internal/devportal/helpers.go @@ -25,6 +25,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -32,6 +33,25 @@ import ( "github.com/wso2/api-platform/cli/internal/config" ) +// APIVersion is the Developer Portal REST API version segment used in all +// organization-scoped resource paths. Bump this in one place when the API +// version changes (for example "v1" -> "v2"). +const APIVersion = "v1" + +// OrgScopedPath builds an organization-scoped Developer Portal resource path of +// the form /o/{orgId}/devportal/{version}/{resource}. The orgID is path-escaped +// here, so callers pass it raw. The resource is appended as-is, so callers +// escape any path segments they interpolate and may include a trailing query +// string (for example "apis/"+url.PathEscape(apiID) or "api-keys?apiId=x"). +// +// Only organization-management endpoints (under /organizations) live outside +// this prefix; every other devportal endpoint should be built through here so +// the /o/{orgId}/devportal/{version} prefix is defined in a single place. +func OrgScopedPath(orgID, resource string) string { + return fmt.Sprintf("/o/%s/devportal/%s/%s", + url.PathEscape(orgID), APIVersion, strings.TrimPrefix(resource, "/")) +} + // ResolveDevPortal resolves the DevPortal to use from either explicit flags // or the active DevPortal in the resolved platform. func ResolveDevPortal(cfg *config.Config, selectedName, selectedPlatform string) (*config.DevPortal, string, error) { From 753ae6818e1097aca0c003bfaa10b25e66d35d80 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Wed, 17 Jun 2026 19:00:19 +0530 Subject: [PATCH 05/27] add basic ai ws related cli commands --- cli/src/cmd/aiws/add.go | 244 ++++++++++++++++++++++ cli/src/cmd/aiws/current.go | 70 +++++++ cli/src/cmd/aiws/list.go | 80 +++++++ cli/src/cmd/aiws/remove.go | 78 +++++++ cli/src/cmd/aiws/root.go | 48 +++++ cli/src/cmd/aiws/use.go | 110 ++++++++++ cli/src/cmd/root.go | 2 + cli/src/internal/config/config.go | 301 +++++++++++++++++++-------- cli/src/internal/project/config.go | 37 +++- cli/src/internal/project/scaffold.go | 35 +++- cli/src/utils/constants.go | 17 +- cli/src/utils/input.go | 38 ++++ 12 files changed, 956 insertions(+), 104 deletions(-) create mode 100644 cli/src/cmd/aiws/add.go create mode 100644 cli/src/cmd/aiws/current.go create mode 100644 cli/src/cmd/aiws/list.go create mode 100644 cli/src/cmd/aiws/remove.go create mode 100644 cli/src/cmd/aiws/root.go create mode 100644 cli/src/cmd/aiws/use.go diff --git a/cli/src/cmd/aiws/add.go b/cli/src/cmd/aiws/add.go new file mode 100644 index 0000000000..7c0418becb --- /dev/null +++ b/cli/src/cmd/aiws/add.go @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + AddCmdLiteral = "add" + AddCmdExample = `# Add a new AI workspace fully interactively +ap ai-ws add + +# Add an AI workspace with basic auth +ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth basic + +# Add an AI workspace with OAuth auth +ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth oauth + +# Add an AI workspace with API key auth +ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth api-key + +# Add an AI workspace without interactive prompts using flags +ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth basic --no-interactive --username admin --password admin +ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth oauth --no-interactive --token your_token_here +ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth api-key --no-interactive --api-key your_api_key_here` +) + +var ( + addName string + addPlatform string + addServer string + addAuth string + addUsername string + addPassword string + addToken string + addAPIKey string + addNoInteractive bool +) + +var addCmd = &cobra.Command{ + Use: AddCmdLiteral, + Short: "Add a new AI workspace", + Long: "Add a new AI workspace configuration to the ap config file.", + Example: AddCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runAddCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(addCmd, utils.FlagName, &addName, "", "Display name of the AI workspace") + utils.AddStringFlag(addCmd, utils.FlagPlatform, &addPlatform, "", "Platform name for the AI workspace") + utils.AddStringFlag(addCmd, utils.FlagServer, &addServer, "", "Server URL of the AI workspace") + utils.AddStringFlag(addCmd, utils.FlagAuth, &addAuth, "", "Authentication type for the AI workspace. Supported values: basic, oauth, api-key") + utils.AddStringFlag(addCmd, utils.FlagUsername, &addUsername, "", "Username for AI workspace basic auth (not recommended, use interactive mode)") + utils.AddStringFlag(addCmd, utils.FlagPassword, &addPassword, "", "Password for AI workspace basic auth (not recommended, use interactive mode)") + utils.AddStringFlag(addCmd, utils.FlagToken, &addToken, "", "Token for AI workspace OAuth auth (not recommended, use interactive mode)") + utils.AddStringFlag(addCmd, utils.FlagAPIKey, &addAPIKey, "", "API key for AI workspace API key auth (not recommended, use interactive mode)") + utils.AddBoolFlag(addCmd, utils.FlagNoInteractive, &addNoInteractive, false, "Skip interactive prompts") +} + +func runAddCommand() error { + var err error + + if !addNoInteractive { + if strings.TrimSpace(addName) == "" { + addName, err = utils.PromptInput("Enter AI workspace display name: ") + if err != nil { + return fmt.Errorf("failed to read display name: %w", err) + } + } + if strings.TrimSpace(addPlatform) == "" { + addPlatform, err = utils.PromptInput(fmt.Sprintf("Enter platform (default: %s): ", config.DefaultPlatform)) + if err != nil { + return fmt.Errorf("failed to read platform: %w", err) + } + } + if strings.TrimSpace(addServer) == "" { + addServer, err = utils.PromptInput("Enter AI workspace server URL: ") + if err != nil { + return fmt.Errorf("failed to read server URL: %w", err) + } + } + if strings.TrimSpace(addAuth) == "" { + addAuth, err = utils.PromptInput("Enter auth type (basic, oauth, api-key): ") + if err != nil { + return fmt.Errorf("failed to read auth type: %w", err) + } + } + } + + addName = strings.TrimSpace(addName) + addPlatform = strings.TrimSpace(addPlatform) + addServer = strings.TrimSpace(addServer) + addAuth = strings.ToLower(strings.TrimSpace(addAuth)) + + if addName == "" { + return fmt.Errorf("missing required flag --%s (or provide it in interactive mode)", utils.FlagName) + } + if addServer == "" { + return fmt.Errorf("missing required flag --%s (or provide it in interactive mode)", utils.FlagServer) + } + if addAuth == "" { + return fmt.Errorf("missing required flag --%s (or provide it in interactive mode)", utils.FlagAuth) + } + if addAuth != utils.AuthTypeBasic && addAuth != utils.AuthTypeOAuth && addAuth != utils.AuthTypeAPIKey { + return fmt.Errorf("invalid auth type '%s'. AI workspace supports only: %s, %s, %s", addAuth, utils.AuthTypeBasic, utils.AuthTypeOAuth, utils.AuthTypeAPIKey) + } + + switch addAuth { + case utils.AuthTypeBasic: + if addToken != "" || addAPIKey != "" { + return fmt.Errorf("--token and --api-key cannot be used with auth type '%s'. Use --username and --password instead", addAuth) + } + case utils.AuthTypeOAuth: + if addUsername != "" || addPassword != "" || addAPIKey != "" { + return fmt.Errorf("--username, --password, and --api-key cannot be used with auth type '%s'. Use --token instead", addAuth) + } + case utils.AuthTypeAPIKey: + if addUsername != "" || addPassword != "" || addToken != "" { + return fmt.Errorf("--username, --password, and --token cannot be used with auth type '%s'. Use --api-key instead", addAuth) + } + } + + if addUsername != "" || addPassword != "" || addToken != "" || addAPIKey != "" { + fmt.Fprintln(os.Stderr, "Warning: Passing credentials via command-line flags is not recommended for security reasons.") + fmt.Fprintln(os.Stderr, "Consider using interactive mode or environment variables instead.") + fmt.Fprintln(os.Stderr, "") + } + + username := addUsername + password := addPassword + token := addToken + apiKey := addAPIKey + + if !addNoInteractive && username == "" && password == "" && token == "" && apiKey == "" { + username, password, token, apiKey, err = utils.PromptAIWorkspaceCredentials(addAuth) + if err != nil { + return fmt.Errorf("failed to read credentials: %w", err) + } + } + + if addAuth == utils.AuthTypeBasic { + if (username != "" && password == "") || (username == "" && password != "") { + return fmt.Errorf("for basic auth, both username and password must be provided, or leave both empty to use environment variables (%s and %s)", + utils.EnvAIWorkspaceUsername, utils.EnvAIWorkspacePassword) + } + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + resolvedPlatform := cfg.ResolvePlatform(addPlatform) + + aiWorkspace := config.AIWorkspace{ + Name: addName, + URL: addServer, + Auth: config.AuthConfig{ + Type: addAuth, + Username: username, + Password: password, + Token: token, + APIKey: apiKey, + }, + } + + if username != "" || password != "" || token != "" || apiKey != "" { + fmt.Fprintln(os.Stderr, "Note: Credentials will be stored in plaintext in the configuration file (mode 0600).") + fmt.Fprintln(os.Stderr, "To avoid storing secrets on disk, omit credentials and use environment variables instead.") + fmt.Fprintln(os.Stderr, "") + } + + if err := cfg.AddAIWorkspaceToPlatform(resolvedPlatform, aiWorkspace); err != nil { + return fmt.Errorf("failed to add AI workspace: %w", err) + } + + if err := config.SaveConfig(cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + configPath, err := config.GetConfigPath() + if err != nil { + utils.LogWarning("could not determine config path", err) + configPath = "(unknown location)" + } + + fmt.Printf("AI workspace %s added (platform: %s, server: %s, auth: %s)\n", addName, resolvedPlatform, addServer, addAuth) + fmt.Printf("Configuration saved to: %s\n", configPath) + + if username != "" || password != "" || token != "" || apiKey != "" { + switch addAuth { + case utils.AuthTypeBasic: + fmt.Printf("Note: Credentials were stored in the configuration; exporting %s and %s will override them at runtime.\n", utils.EnvAIWorkspaceUsername, utils.EnvAIWorkspacePassword) + case utils.AuthTypeOAuth: + fmt.Printf("Note: Credentials were stored in the configuration; exporting %s will override them at runtime.\n", utils.EnvAIWorkspaceToken) + case utils.AuthTypeAPIKey: + fmt.Printf("Note: Credentials were stored in the configuration; exporting %s will override them at runtime.\n", utils.EnvAIWorkspaceAPIKey) + } + } else { + fmt.Println() + lines := []string{ + "No credentials stored for this AI workspace.", + "Set the following environment variable(s) before making AI workspace API calls:", + } + switch addAuth { + case utils.AuthTypeBasic: + lines = append(lines, " "+utils.EnvAIWorkspaceUsername, " "+utils.EnvAIWorkspacePassword) + case utils.AuthTypeOAuth: + lines = append(lines, " "+utils.EnvAIWorkspaceToken) + case utils.AuthTypeAPIKey: + lines = append(lines, " "+utils.EnvAIWorkspaceAPIKey) + } + utils.PrintBoxedMessage(lines) + } + + return nil +} diff --git a/cli/src/cmd/aiws/current.go b/cli/src/cmd/aiws/current.go new file mode 100644 index 0000000000..22216db505 --- /dev/null +++ b/cli/src/cmd/aiws/current.go @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + CurrentCmdLiteral = "current" + CurrentCmdExample = `# Show the current active AI workspace +ap ai-ws current` +) + +var currentCmd = &cobra.Command{ + Use: CurrentCmdLiteral, + Short: "Show the current active AI workspace", + Long: "Display the current active AI workspace configuration.", + Example: CurrentCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runCurrentCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +var currentPlatform string + +func init() { + utils.AddStringFlag(currentCmd, utils.FlagPlatform, ¤tPlatform, "", "Platform name") +} + +func runCurrentCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + resolvedPlatform := cfg.ResolvePlatform(currentPlatform) + aiWorkspace, err := cfg.GetActiveAIWorkspaceFromPlatform(resolvedPlatform) + if err != nil { + return err + } + + fmt.Printf("Current ai-workspace: %s - %s (platform: %s, auth: %s)\n", aiWorkspace.Name, aiWorkspace.URL, resolvedPlatform, aiWorkspace.Auth.Type) + + return nil +} diff --git a/cli/src/cmd/aiws/list.go b/cli/src/cmd/aiws/list.go new file mode 100644 index 0000000000..38058618f5 --- /dev/null +++ b/cli/src/cmd/aiws/list.go @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + ListCmdLiteral = "list" + ListCmdExample = `# List all AI workspaces +ap ai-ws list` +) + +var listCmd = &cobra.Command{ + Use: ListCmdLiteral, + Short: "List all AI workspaces", + Long: "List all AI workspace configurations from the ap config file.", + Example: ListCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runListCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +var listPlatform string + +func init() { + utils.AddStringFlag(listCmd, utils.FlagPlatform, &listPlatform, "", "Platform name") +} + +func runListCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + resolvedPlatform := cfg.ResolvePlatform(listPlatform) + platform := cfg.Platforms[resolvedPlatform] + if platform == nil || len(platform.AIWorkspaces) == 0 { + fmt.Printf("No ai-workspace configured for platform %s\n", resolvedPlatform) + return nil + } + + headers := []string{"PLATFORM", "NAME", "URL", "AUTH", "CURRENT"} + rows := make([][]string, 0, len(platform.AIWorkspaces)) + for name, ws := range platform.AIWorkspaces { + current := "" + if name == platform.ActiveAIWorkspace { + current = "*" + } + rows = append(rows, []string{resolvedPlatform, name, ws.URL, ws.Auth.Type, current}) + } + utils.PrintTable(headers, rows) + + return nil +} diff --git a/cli/src/cmd/aiws/remove.go b/cli/src/cmd/aiws/remove.go new file mode 100644 index 0000000000..009aecb92d --- /dev/null +++ b/cli/src/cmd/aiws/remove.go @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + RemoveCmdLiteral = "remove" + RemoveCmdExample = `# Remove an AI workspace +ap ai-ws remove --display-name my-workspace` +) + +var ( + removeName string + removePlatform string +) + +var removeCmd = &cobra.Command{ + Use: RemoveCmdLiteral, + Short: "Remove an AI workspace", + Long: "Remove an AI workspace configuration from the ap config file.", + Example: RemoveCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runRemoveCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(removeCmd, utils.FlagName, &removeName, "", "Name of the AI workspace to remove (required)") + utils.AddStringFlag(removeCmd, utils.FlagPlatform, &removePlatform, "", "Platform name") + removeCmd.MarkFlagRequired(utils.FlagName) +} + +func runRemoveCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + resolvedPlatform := cfg.ResolvePlatform(removePlatform) + if err := cfg.RemoveAIWorkspaceFromPlatform(resolvedPlatform, removeName); err != nil { + return err + } + + if err := config.SaveConfig(cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println("AI workspace removed successfully.") + + return nil +} diff --git a/cli/src/cmd/aiws/root.go b/cli/src/cmd/aiws/root.go new file mode 100644 index 0000000000..fc2e049dab --- /dev/null +++ b/cli/src/cmd/aiws/root.go @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "github.com/spf13/cobra" +) + +const ( + AiWSCmdLiteral = "ai-ws" + AiWSCmdExample = `# Add a new AI-Workspace +ap ai-ws add --display-name my-portal --platform eu --server https://ai-workspace.example.com --auth api-key` +) + +var AiWSCmd = &cobra.Command{ + Use: AiWSCmdLiteral, + Short: "Execute AI-Workspace operations", + Long: "This command allows you to execute various operations related to AI-Workspaces.", + Example: AiWSCmdExample, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + AiWSCmd.AddCommand(addCmd) + AiWSCmd.AddCommand(listCmd) + AiWSCmd.AddCommand(removeCmd) + AiWSCmd.AddCommand(useCmd) + AiWSCmd.AddCommand(currentCmd) + AiWSCmd.AddCommand(buildCmd) +} diff --git a/cli/src/cmd/aiws/use.go b/cli/src/cmd/aiws/use.go new file mode 100644 index 0000000000..ff998a56f1 --- /dev/null +++ b/cli/src/cmd/aiws/use.go @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + UseCmdLiteral = "use" + UseCmdExample = `# Set my-workspace as the active AI workspace +ap ai-ws use --display-name my-workspace` +) + +var ( + useName string + usePlatform string +) + +var useCmd = &cobra.Command{ + Use: UseCmdLiteral, + Short: "Set the active AI workspace", + Long: "Set the active AI workspace that will be used by default for operations.", + Example: UseCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runUseCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(useCmd, utils.FlagName, &useName, "", "Name of the AI workspace to use (required)") + utils.AddStringFlag(useCmd, utils.FlagPlatform, &usePlatform, "", "Platform name") + useCmd.MarkFlagRequired(utils.FlagName) +} + +func runUseCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + resolvedPlatform := cfg.ResolvePlatform(usePlatform) + aiWorkspace, err := cfg.GetAIWorkspaceFromPlatform(resolvedPlatform, useName) + if err != nil { + return err + } + + if err := cfg.SetActiveAIWorkspaceForPlatform(resolvedPlatform, useName); err != nil { + return err + } + + if err := config.SaveConfig(cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("AI workspace set to %s (platform: %s, auth: %s).\n", useName, resolvedPlatform, aiWorkspace.Auth.Type) + + hasEnvCreds := false + hasConfigCreds := false + message := "" + + switch aiWorkspace.Auth.Type { + case utils.AuthTypeBasic: + hasEnvCreds = os.Getenv(utils.EnvAIWorkspaceUsername) != "" && os.Getenv(utils.EnvAIWorkspacePassword) != "" + hasConfigCreds = aiWorkspace.Auth.Username != "" && aiWorkspace.Auth.Password != "" + message = fmt.Sprintf("\nBasic authentication requires the following environment variables:\n %s\n %s\n", utils.EnvAIWorkspaceUsername, utils.EnvAIWorkspacePassword) + case utils.AuthTypeOAuth: + hasEnvCreds = os.Getenv(utils.EnvAIWorkspaceToken) != "" + hasConfigCreds = aiWorkspace.Auth.Token != "" + message = fmt.Sprintf("\nOAuth authentication requires the following environment variable:\n %s\n", utils.EnvAIWorkspaceToken) + case utils.AuthTypeAPIKey: + hasEnvCreds = os.Getenv(utils.EnvAIWorkspaceAPIKey) != "" + hasConfigCreds = aiWorkspace.Auth.APIKey != "" + message = fmt.Sprintf("\nAPI key authentication requires the following environment variable:\n %s\n", utils.EnvAIWorkspaceAPIKey) + } + + if !hasEnvCreds && !hasConfigCreds { + fmt.Print(message) + } else if hasConfigCreds && !hasEnvCreds { + fmt.Println("Using credentials from configuration.") + } else if hasEnvCreds { + fmt.Println("Using credentials from environment variables.") + } + + return nil +} diff --git a/cli/src/cmd/root.go b/cli/src/cmd/root.go index 22bf285517..2c05cbdec5 100644 --- a/cli/src/cmd/root.go +++ b/cli/src/cmd/root.go @@ -23,6 +23,7 @@ import ( "os" "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/cmd/aiws" "github.com/wso2/api-platform/cli/cmd/devportal" "github.com/wso2/api-platform/cli/cmd/gateway" "github.com/wso2/api-platform/cli/cmd/platform" @@ -67,6 +68,7 @@ var versionCmd = &cobra.Command{ } func init() { + rootCmd.AddCommand(aiws.AiWSCmd) rootCmd.AddCommand(devportal.DevPortalCmd) rootCmd.AddCommand(gateway.GatewayCmd) rootCmd.AddCommand(platform.PlatformCmd) diff --git a/cli/src/internal/config/config.go b/cli/src/internal/config/config.go index b0b51509bf..984d4da761 100644 --- a/cli/src/internal/config/config.go +++ b/cli/src/internal/config/config.go @@ -41,10 +41,10 @@ type AuthConfig struct { // Gateway represents a gateway configuration. type Gateway struct { - Name string `yaml:"-"` - Server string `yaml:"server"` - Auth AuthConfig `yaml:"auth,omitempty"` - AdminServer string `yaml:"adminServer,omitempty"` + Name string `yaml:"-"` + Server string `yaml:"server"` + Auth AuthConfig `yaml:"auth,omitempty"` + AdminServer string `yaml:"adminServer,omitempty"` } // DevPortal represents a developer portal configuration. @@ -54,12 +54,21 @@ type DevPortal struct { Auth AuthConfig `yaml:"auth,omitempty"` } +// AIWorkspace represents an AI workspace configuration. +type AIWorkspace struct { + Name string `yaml:"-"` + URL string `yaml:"url"` + Auth AuthConfig `yaml:"auth,omitempty"` +} + // Platform groups the CLI resources that belong to a single platform. type Platform struct { - Gateways map[string]*Gateway `yaml:"gateways,omitempty"` - ActiveGateway string `yaml:"activeGateway,omitempty"` - DevPortals map[string]*DevPortal `yaml:"devportals,omitempty"` - ActiveDevPortal string `yaml:"activeDevPortal,omitempty"` + Gateways map[string]*Gateway `yaml:"gateways,omitempty"` + ActiveGateway string `yaml:"activeGateway,omitempty"` + DevPortals map[string]*DevPortal `yaml:"devportals,omitempty"` + ActiveDevPortal string `yaml:"activeDevPortal,omitempty"` + AIWorkspaces map[string]*AIWorkspace `yaml:"aiWorkspaces,omitempty"` + ActiveAIWorkspace string `yaml:"activeAIWorkspace,omitempty"` } // Config represents the ap configuration file. @@ -94,6 +103,15 @@ func normalizeDevPortalAuth(devPortal *DevPortal) { } } +func normalizeAIWorkspaceAuth(aiWorkspace *AIWorkspace) { + if aiWorkspace == nil { + return + } + if aiWorkspace.Auth.Type == "" { + aiWorkspace.Auth.Type = utils.AuthTypeAPIKey + } +} + func normalizePlatform(platform *Platform) { if platform == nil { return @@ -120,6 +138,17 @@ func normalizePlatform(platform *Platform) { devPortal.Name = name normalizeDevPortalAuth(devPortal) } + if platform.AIWorkspaces == nil { + platform.AIWorkspaces = map[string]*AIWorkspace{} + } + for name, aiWorkspace := range platform.AIWorkspaces { + if aiWorkspace == nil { + aiWorkspace = &AIWorkspace{} + platform.AIWorkspaces[name] = aiWorkspace + } + aiWorkspace.Name = name + normalizeAIWorkspaceAuth(aiWorkspace) + } } func (c *Config) ensurePlatform(platform string) *Platform { @@ -275,14 +304,37 @@ func SaveConfig(config *Config) error { return nil } -func (c *Config) AddGatewayToPlatform(platformName string, gateway Gateway) error { +// resourceSection describes one named-resource map on a Platform plus its +// companion "active" pointer. A single value of this type captures everything +// that varies between gateways, devportals and ai-workspaces (which map to +// read, which active field to track, the error label, and how to read/write a +// resource's name and per-kind defaults), so the generic CRUD helpers below are +// written once and reused across all three resource kinds. +// +// The accessors exist because Go generics abstract over behaviour, not struct +// fields: a type parameter T cannot reach `.Name`, `p.DevPortals`, etc. +// directly, so the section supplies small closures that do. +type resourceSection[T any] struct { + label string // resource name used in error messages + items func(*Platform) map[string]*T // the per-kind map on a Platform + active func(*Platform) string // reads the "active" pointer + setActive func(*Platform, string) // writes the "active" pointer + getName func(*T) string // reads a resource's name + setName func(*T, string) // writes a resource's name + normalize func(*T) // applies per-kind defaults (e.g. auth type) +} + +// add stores resource under its trimmed name, making it active if no active +// resource is set yet. +func (s resourceSection[T]) add(c *Config, platformName string, resource *T) error { platformName = c.ResolvePlatform(platformName) platform := c.ensurePlatform(platformName) - gateway.Name = strings.TrimSpace(gateway.Name) - normalizeGatewayAuth(&gateway) - platform.Gateways[gateway.Name] = &gateway - if platform.ActiveGateway == "" { - platform.ActiveGateway = gateway.Name + name := strings.TrimSpace(s.getName(resource)) + s.setName(resource, name) + s.normalize(resource) + s.items(platform)[name] = resource + if s.active(platform) == "" { + s.setActive(platform, name) } if c.CurrentPlatform == "" { c.CurrentPlatform = platformName @@ -290,38 +342,104 @@ func (c *Config) AddGatewayToPlatform(platformName string, gateway Gateway) erro return nil } -func (c *Config) AddGateway(gateway Gateway) error { - return c.AddGatewayToPlatform("", gateway) +// get looks up a resource by name, stamping the name back onto the returned +// value so callers always see it populated. +func (s resourceSection[T]) get(c *Config, platformName, name string) (*T, error) { + platformName = c.ResolvePlatform(platformName) + platform := c.ensurePlatform(platformName) + resource, ok := s.items(platform)[name] + if !ok { + return nil, fmt.Errorf("%s '%s' not found in platform '%s'", s.label, name, platformName) + } + s.setName(resource, name) + return resource, nil } -func (c *Config) AddDevPortalToPlatform(platformName string, devPortal DevPortal) error { +// getActive returns the platform's currently active resource of this kind. +func (s resourceSection[T]) getActive(c *Config, platformName string) (*T, error) { platformName = c.ResolvePlatform(platformName) platform := c.ensurePlatform(platformName) - devPortal.Name = strings.TrimSpace(devPortal.Name) - normalizeDevPortalAuth(&devPortal) - platform.DevPortals[devPortal.Name] = &devPortal - if platform.ActiveDevPortal == "" { - platform.ActiveDevPortal = devPortal.Name + activeName := s.active(platform) + if activeName == "" { + return nil, fmt.Errorf("no active %s set for platform '%s'", s.label, platformName) } - if c.CurrentPlatform == "" { - c.CurrentPlatform = platformName - } - return nil + return s.get(c, platformName, activeName) } -func (c *Config) AddDevPortal(devPortal DevPortal) error { - return c.AddDevPortalToPlatform("", devPortal) +// setActiveByName marks an existing resource as active (failing if it does not +// exist) and switches the current platform to its owner. +func (s resourceSection[T]) setActiveByName(c *Config, platformName, name string) error { + platformName = c.ResolvePlatform(platformName) + if _, err := s.get(c, platformName, name); err != nil { + return err + } + platform := c.ensurePlatform(platformName) + s.setActive(platform, name) + c.CurrentPlatform = platformName + return nil } -func (c *Config) GetGatewayFromPlatform(platformName, name string) (*Gateway, error) { +// remove deletes a resource, clearing the active pointer when it referenced the +// removed resource. +func (s resourceSection[T]) remove(c *Config, platformName, name string) error { platformName = c.ResolvePlatform(platformName) platform := c.ensurePlatform(platformName) - gateway, ok := platform.Gateways[name] - if !ok { - return nil, fmt.Errorf("gateway '%s' not found in platform '%s'", name, platformName) + if _, ok := s.items(platform)[name]; !ok { + return fmt.Errorf("%s '%s' not found in platform '%s'", s.label, name, platformName) } - gateway.Name = name - return gateway, nil + delete(s.items(platform), name) + if s.active(platform) == name { + s.setActive(platform, "") + } + return nil +} + +// Per-kind section descriptors. These are the only place the concrete fields of +// each resource are wired into the generic helpers. +var ( + gatewaySection = resourceSection[Gateway]{ + label: "gateway", + items: func(p *Platform) map[string]*Gateway { return p.Gateways }, + active: func(p *Platform) string { return p.ActiveGateway }, + setActive: func(p *Platform, n string) { p.ActiveGateway = n }, + getName: func(g *Gateway) string { return g.Name }, + setName: func(g *Gateway, n string) { g.Name = n }, + normalize: normalizeGatewayAuth, + } + + devPortalSection = resourceSection[DevPortal]{ + label: "devportal", + items: func(p *Platform) map[string]*DevPortal { return p.DevPortals }, + active: func(p *Platform) string { return p.ActiveDevPortal }, + setActive: func(p *Platform, n string) { p.ActiveDevPortal = n }, + getName: func(d *DevPortal) string { return d.Name }, + setName: func(d *DevPortal, n string) { d.Name = n }, + normalize: normalizeDevPortalAuth, + } + + aiWorkspaceSection = resourceSection[AIWorkspace]{ + label: "ai-workspace", + items: func(p *Platform) map[string]*AIWorkspace { return p.AIWorkspaces }, + active: func(p *Platform) string { return p.ActiveAIWorkspace }, + setActive: func(p *Platform, n string) { p.ActiveAIWorkspace = n }, + getName: func(w *AIWorkspace) string { return w.Name }, + setName: func(w *AIWorkspace, n string) { w.Name = n }, + normalize: normalizeAIWorkspaceAuth, + } +) + +// --- Gateway public API (thin, type-safe wrappers over gatewaySection) --- + +func (c *Config) AddGatewayToPlatform(platformName string, gateway Gateway) error { + return gatewaySection.add(c, platformName, &gateway) +} + +func (c *Config) AddGateway(gateway Gateway) error { + return c.AddGatewayToPlatform("", gateway) +} + +func (c *Config) GetGatewayFromPlatform(platformName, name string) (*Gateway, error) { + return gatewaySection.get(c, platformName, name) } func (c *Config) GetGateway(name string) (*Gateway, error) { @@ -329,12 +447,7 @@ func (c *Config) GetGateway(name string) (*Gateway, error) { } func (c *Config) GetActiveGatewayFromPlatform(platformName string) (*Gateway, error) { - platformName = c.ResolvePlatform(platformName) - platform := c.ensurePlatform(platformName) - if platform.ActiveGateway == "" { - return nil, fmt.Errorf("no active gateway set for platform '%s'", platformName) - } - return c.GetGatewayFromPlatform(platformName, platform.ActiveGateway) + return gatewaySection.getActive(c, platformName) } func (c *Config) GetActiveGateway() (*Gateway, error) { @@ -342,14 +455,7 @@ func (c *Config) GetActiveGateway() (*Gateway, error) { } func (c *Config) SetActiveGatewayForPlatform(platformName, name string) error { - platformName = c.ResolvePlatform(platformName) - if _, err := c.GetGatewayFromPlatform(platformName, name); err != nil { - return err - } - platform := c.ensurePlatform(platformName) - platform.ActiveGateway = name - c.CurrentPlatform = platformName - return nil + return gatewaySection.setActiveByName(c, platformName, name) } func (c *Config) SetActiveGateway(name string) error { @@ -357,31 +463,25 @@ func (c *Config) SetActiveGateway(name string) error { } func (c *Config) RemoveGatewayFromPlatform(platformName, name string) error { - platformName = c.ResolvePlatform(platformName) - platform := c.ensurePlatform(platformName) - if _, ok := platform.Gateways[name]; !ok { - return fmt.Errorf("gateway '%s' not found in platform '%s'", name, platformName) - } - delete(platform.Gateways, name) - if platform.ActiveGateway == name { - platform.ActiveGateway = "" - } - return nil + return gatewaySection.remove(c, platformName, name) } func (c *Config) RemoveGateway(name string) error { return c.RemoveGatewayFromPlatform("", name) } +// --- DevPortal public API (thin, type-safe wrappers over devPortalSection) --- + +func (c *Config) AddDevPortalToPlatform(platformName string, devPortal DevPortal) error { + return devPortalSection.add(c, platformName, &devPortal) +} + +func (c *Config) AddDevPortal(devPortal DevPortal) error { + return c.AddDevPortalToPlatform("", devPortal) +} + func (c *Config) GetDevPortalFromPlatform(platformName, name string) (*DevPortal, error) { - platformName = c.ResolvePlatform(platformName) - platform := c.ensurePlatform(platformName) - devPortal, ok := platform.DevPortals[name] - if !ok { - return nil, fmt.Errorf("devportal '%s' not found in platform '%s'", name, platformName) - } - devPortal.Name = name - return devPortal, nil + return devPortalSection.get(c, platformName, name) } func (c *Config) GetDevPortal(name string) (*DevPortal, error) { @@ -389,12 +489,7 @@ func (c *Config) GetDevPortal(name string) (*DevPortal, error) { } func (c *Config) GetActiveDevPortalFromPlatform(platformName string) (*DevPortal, error) { - platformName = c.ResolvePlatform(platformName) - platform := c.ensurePlatform(platformName) - if platform.ActiveDevPortal == "" { - return nil, fmt.Errorf("no active devportal set for platform '%s'", platformName) - } - return c.GetDevPortalFromPlatform(platformName, platform.ActiveDevPortal) + return devPortalSection.getActive(c, platformName) } func (c *Config) GetActiveDevPortal() (*DevPortal, error) { @@ -402,14 +497,7 @@ func (c *Config) GetActiveDevPortal() (*DevPortal, error) { } func (c *Config) SetActiveDevPortalForPlatform(platformName, name string) error { - platformName = c.ResolvePlatform(platformName) - if _, err := c.GetDevPortalFromPlatform(platformName, name); err != nil { - return err - } - platform := c.ensurePlatform(platformName) - platform.ActiveDevPortal = name - c.CurrentPlatform = platformName - return nil + return devPortalSection.setActiveByName(c, platformName, name) } func (c *Config) SetActiveDevPortal(name string) error { @@ -417,18 +505,51 @@ func (c *Config) SetActiveDevPortal(name string) error { } func (c *Config) RemoveDevPortalFromPlatform(platformName, name string) error { - platformName = c.ResolvePlatform(platformName) - platform := c.ensurePlatform(platformName) - if _, ok := platform.DevPortals[name]; !ok { - return fmt.Errorf("devportal '%s' not found in platform '%s'", name, platformName) - } - delete(platform.DevPortals, name) - if platform.ActiveDevPortal == name { - platform.ActiveDevPortal = "" - } - return nil + return devPortalSection.remove(c, platformName, name) } func (c *Config) RemoveDevPortal(name string) error { return c.RemoveDevPortalFromPlatform("", name) } + +// --- AIWorkspace public API (thin, type-safe wrappers over aiWorkspaceSection) --- + +func (c *Config) AddAIWorkspaceToPlatform(platformName string, aiWorkspace AIWorkspace) error { + return aiWorkspaceSection.add(c, platformName, &aiWorkspace) +} + +func (c *Config) AddAIWorkspace(aiWorkspace AIWorkspace) error { + return c.AddAIWorkspaceToPlatform("", aiWorkspace) +} + +func (c *Config) GetAIWorkspaceFromPlatform(platformName, name string) (*AIWorkspace, error) { + return aiWorkspaceSection.get(c, platformName, name) +} + +func (c *Config) GetAIWorkspace(name string) (*AIWorkspace, error) { + return c.GetAIWorkspaceFromPlatform("", name) +} + +func (c *Config) GetActiveAIWorkspaceFromPlatform(platformName string) (*AIWorkspace, error) { + return aiWorkspaceSection.getActive(c, platformName) +} + +func (c *Config) GetActiveAIWorkspace() (*AIWorkspace, error) { + return c.GetActiveAIWorkspaceFromPlatform("") +} + +func (c *Config) SetActiveAIWorkspaceForPlatform(platformName, name string) error { + return aiWorkspaceSection.setActiveByName(c, platformName, name) +} + +func (c *Config) SetActiveAIWorkspace(name string) error { + return c.SetActiveAIWorkspaceForPlatform("", name) +} + +func (c *Config) RemoveAIWorkspaceFromPlatform(platformName, name string) error { + return aiWorkspaceSection.remove(c, platformName, name) +} + +func (c *Config) RemoveAIWorkspace(name string) error { + return c.RemoveAIWorkspaceFromPlatform("", name) +} diff --git a/cli/src/internal/project/config.go b/cli/src/internal/project/config.go index 1a3ab38b2e..eee281d805 100644 --- a/cli/src/internal/project/config.go +++ b/cli/src/internal/project/config.go @@ -55,9 +55,8 @@ type FilePaths struct { Tests string `yaml:"tests,omitempty"` } -// PortalFilePaths describes a portal-specific artifact layout. Every portal -// section (devportals, aiWorkspaces) shares the same {name, portalRoot, -// filePaths} layout; only the contents of filePaths are portal specific. +// PortalFilePaths describes a devportal's artifact layout, relative to its +// portalRoot. type PortalFilePaths struct { MetadataFile string `yaml:"metadataFile,omitempty"` Definition string `yaml:"definition,omitempty"` @@ -65,14 +64,40 @@ type PortalFilePaths struct { Content string `yaml:"content,omitempty"` } -// PortalConfig is the shared layout for any portal section in the project -// config (devportals, aiWorkspaces, ...). +// PortalConfig is the layout for a devportal section in the project config. type PortalConfig struct { Name string `yaml:"name,omitempty"` PortalRoot string `yaml:"portalRoot,omitempty"` FilePaths PortalFilePaths `yaml:"filePaths,omitempty"` } +// Default file paths for an ai-workspace portal config, relative to its +// portalRoot. +const ( + DefaultAIWorkspaceMetadata = "./metadata.yaml" + DefaultAIWorkspaceRuntime = "./runtime.yaml" + DefaultAIWorkspaceDefinition = "./definition.yaml" + DefaultAIWorkspaceDocs = "./docs" +) + +// AIWorkspaceFilePaths describes an ai-workspace portal's artifact layout, +// relative to its portalRoot. Unlike a devportal it carries a runtime artifact. +// Definition is the optional OpenAPI spec folded into the generated llm-proxy +// payload. +type AIWorkspaceFilePaths struct { + Metadata string `yaml:"metadata,omitempty"` + Runtime string `yaml:"runtime,omitempty"` + Definition string `yaml:"definition,omitempty"` +} + +// AIWorkspaceConfig is one ai-workspace portal configuration in the project +// config. +type AIWorkspaceConfig struct { + Name string `yaml:"name,omitempty"` + PortalRoot string `yaml:"portalRoot,omitempty"` + FilePaths AIWorkspaceFilePaths `yaml:"filePaths,omitempty"` +} + // Config is the on-disk .api-platform/config.yaml for an api-platform project. type Config struct { Version string `yaml:"version,omitempty"` @@ -80,7 +105,7 @@ type Config struct { GovernanceRulesets []string `yaml:"governanceRulesets"` AutoSync map[string]interface{} `yaml:"autoSync,omitempty"` DevPortals []PortalConfig `yaml:"devportals,omitempty"` - AIWorkspaces []PortalConfig `yaml:"aiWorkspaces,omitempty"` + AIWorkspaces []AIWorkspaceConfig `yaml:"ai-workspaces,omitempty"` } // DefaultFilePaths returns the project-level file paths used when scaffolding a diff --git a/cli/src/internal/project/scaffold.go b/cli/src/internal/project/scaffold.go index 788151bd2b..77f77c53fb 100644 --- a/cli/src/internal/project/scaffold.go +++ b/cli/src/internal/project/scaffold.go @@ -95,9 +95,35 @@ type runtimeOperation struct { Method string `yaml:"method"` } +// portalConfigTemplate is appended (commented out) to a freshly scaffolded +// config.yaml so users have a ready-to-edit reference for wiring up +// ai-workspace and devportal publishing targets. Uncomment and adjust to add a +// portal; the keys match the structs the build commands parse. +const portalConfigTemplate = ` +# AI-Workspace portal configurations +# ai-workspaces: +# - name: dev +# portalRoot: ./ai-workspace +# filePaths: # paths relative to portal root +# metadata: ./artifact.yaml +# runtime: ./runtime.yaml +# definition: ./definition.yaml # only folded into the payload with --use-spec + +# Dev portal configurations +# devportals: +# - name: default +# portalRoot: ./devportal +# filePaths: # paths relative to portal root +# metadata: ./devportal.yaml +# definition: ./definition.yaml +# docs: ./docs +# content: ./content +` + // BuildConfigYAML renders the default .api-platform/config.yaml for a new // project, sourcing the file-path values from the shared FilePaths struct so -// the scaffold and the loader can never drift apart. +// the scaffold and the loader can never drift apart. A commented-out portal +// configuration template is appended for the user to edit. func BuildConfigYAML() (string, error) { config := Config{ Version: DefaultConfigVersion, @@ -108,13 +134,18 @@ func BuildConfigYAML() (string, error) { }, } - return renderYAML(config, map[string]string{ + rendered, err := renderYAML(config, map[string]string{ "filePaths": "Default file paths (can be customized)", "governanceRulesets": "Governance rulesets for design-time validation", "autoSync": "Auto-sync configuration for vscode plugin", }, map[string]string{ "autoSync.gatewayArtifactFromDefinition": "Auto-generate runtime.yaml when definition.yaml changes", }) + if err != nil { + return "", err + } + + return rendered + portalConfigTemplate, nil } // BuildMetadataYAML renders the default metadata.yaml for the given artifact diff --git a/cli/src/utils/constants.go b/cli/src/utils/constants.go index ee942cfa63..e5a2c23089 100644 --- a/cli/src/utils/constants.go +++ b/cli/src/utils/constants.go @@ -76,6 +76,12 @@ const ( EnvDevPortalAPIKey = "WSO2AP_DEVPORTAL_API_KEY" // For DevPortal API key auth DevPortalAPIHeader = "x-wso2-api-key" + // AI Workspace Authentication Environment Variables + EnvAIWorkspaceUsername = "WSO2AP_AIWORKSPACE_USERNAME" // For AI workspace basic auth + EnvAIWorkspacePassword = "WSO2AP_AIWORKSPACE_PASSWORD" // For AI workspace basic auth + EnvAIWorkspaceToken = "WSO2AP_AIWORKSPACE_TOKEN" // For AI workspace OAuth auth + EnvAIWorkspaceAPIKey = "WSO2AP_AIWORKSPACE_API_KEY" // For AI workspace API key auth + // Image Build Configuration GatewayVerifyChecksumOnBuild = true @@ -85,13 +91,12 @@ const ( MaxTotalUncompressed = 100 * 1024 * 1024 // Maximum total uncompressed size allowed for the archive (100 MB). // Artifact Types - TypeREST = "rest" - TypeSOAP = "soap" - TypeLLMProxy = "llm-proxy" - TypeLLMProvider = "llm-provider" + TypeREST = "rest" + TypeSOAP = "soap" + TypeLLMProxy = "llm-proxy" + TypeLLMProvider = "llm-provider" TypeLLMProviderTemplate = "llm-provider-template" - TypeMCPProxy = "mcp-proxy" - + TypeMCPProxy = "mcp-proxy" ) // PolicyHub REST API defaults and paths diff --git a/cli/src/utils/input.go b/cli/src/utils/input.go index dfc6d696e5..2d267c31dd 100644 --- a/cli/src/utils/input.go +++ b/cli/src/utils/input.go @@ -142,3 +142,41 @@ func PromptDevPortalCredentials(authType string) (username, password, token, api return "", "", "", "", fmt.Errorf("unsupported devportal auth type '%s'", authType) } } + +// PromptAIWorkspaceCredentials prompts for AI workspace credentials based on auth type. +// Empty values indicate user chose to use environment variables. +func PromptAIWorkspaceCredentials(authType string) (username, password, token, apiKey string, err error) { + switch authType { + case AuthTypeBasic: + username, err = PromptInput(fmt.Sprintf("Enter AI workspace username (leave empty to use %s env var): ", EnvAIWorkspaceUsername)) + if err != nil { + return "", "", "", "", err + } + + password, err = PromptPassword(fmt.Sprintf("Enter AI workspace password (leave empty to use %s env var): ", EnvAIWorkspacePassword)) + if err != nil { + return "", "", "", "", err + } + + return username, password, "", "", nil + + case AuthTypeOAuth: + token, err = PromptPassword(fmt.Sprintf("Enter AI workspace OAuth token (leave empty to use %s env var): ", EnvAIWorkspaceToken)) + if err != nil { + return "", "", "", "", err + } + + return "", "", token, "", nil + + case AuthTypeAPIKey: + apiKey, err = PromptPassword(fmt.Sprintf("Enter AI workspace API key (leave empty to use %s env var): ", EnvAIWorkspaceAPIKey)) + if err != nil { + return "", "", "", "", err + } + + return "", "", "", apiKey, nil + + default: + return "", "", "", "", fmt.Errorf("unsupported ai-workspace auth type '%s'", authType) + } +} From 8738522b1e825bf44aa7d4289e9527fc04ba1fd4 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Wed, 17 Jun 2026 19:00:46 +0530 Subject: [PATCH 06/27] add ai ws build command --- cli/src/cmd/aiws/build.go | 536 ++++++++++++++++++++++++++++++++++++++ cli/src/utils/flags.go | 1 + 2 files changed, 537 insertions(+) create mode 100644 cli/src/cmd/aiws/build.go diff --git a/cli/src/cmd/aiws/build.go b/cli/src/cmd/aiws/build.go new file mode 100644 index 0000000000..55b5029bcf --- /dev/null +++ b/cli/src/cmd/aiws/build.go @@ -0,0 +1,536 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/project" + "github.com/wso2/api-platform/cli/utils" + "gopkg.in/yaml.v3" +) + +const ( + BuildCmdLiteral = "build" + BuildCmdExample = `# Build the AI workspace artifact in the current directory +ap ai-ws build + +# Build from a specific project directory +ap ai-ws build -f /path/to/project + +# Write the generated payload to a specific directory +ap ai-ws build -o build/ + +# Write the generated payload to a specific file +ap ai-ws build -o build/openai.json + +# Build and fold the OpenAPI spec (definition.yaml) into the payload +ap ai-ws build --use-spec` +) + +var ( + buildProjectDir string + buildOutputDir string + buildUseSpec bool +) + +var buildCmd = &cobra.Command{ + Use: BuildCmdLiteral, + Short: "Build the project for AI workspace", + Long: "Build the AI workspace artifact for the project located in the specified directory " + + "(or current directory if not specified). For each ai-workspace configuration in " + + ".api-platform/config.yaml, the command reads its metadata.yaml and runtime.yaml and generates " + + "an llm-proxy creation payload as a JSON file. The openapi field is left empty by default; pass " + + "--use-spec to fold in the OpenAPI spec from definition.yaml when it exists.", + Example: BuildCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runBuildCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(buildCmd, utils.FlagFile, &buildProjectDir, "", "Path to the project directory (defaults to current directory)") + utils.AddStringFlag(buildCmd, utils.FlagOutput, &buildOutputDir, "", "Output path: a .json file to write the payload to, or a directory (defaults to the project build directory)") + utils.AddBoolFlag(buildCmd, utils.FlagUseSpec, &buildUseSpec, false, "Fold the OpenAPI spec (definition.yaml) into the generated payload when it exists") +} + +// failedWorkspace records an ai-workspace config that could not be built so the +// others can still be generated and the failures reported together. +type failedWorkspace struct { + name string + err error +} + +func runBuildCommand() error { + if buildProjectDir == "" { + buildProjectDir = "." + } + + projectRoot, err := filepath.Abs(buildProjectDir) + if err != nil { + return fmt.Errorf("failed to resolve project directory: %w", err) + } + + projectConfigDir := filepath.Join(projectRoot, ".api-platform") + if _, err := os.Stat(projectConfigDir); os.IsNotExist(err) { + return fmt.Errorf("unable to find project directory, please execute this command inside a project") + } else if err != nil { + return fmt.Errorf("failed to inspect project directory: %w", err) + } + + projectConfigPath := filepath.Join(projectConfigDir, "config.yaml") + if _, err := os.Stat(projectConfigPath); os.IsNotExist(err) { + return fmt.Errorf("unable to find project directory, please execute this command inside a project") + } else if err != nil { + return fmt.Errorf("failed to inspect project config: %w", err) + } + + projectConfig, err := project.Load(projectConfigPath) + if err != nil { + return err + } + + // Create a default ai-workspace config if none exists and persist it so the + // project records the configuration that was built. + if len(projectConfig.AIWorkspaces) == 0 { + projectConfig.AIWorkspaces = append(projectConfig.AIWorkspaces, project.AIWorkspaceConfig{ + Name: "default", + PortalRoot: ".", + }) + if err := project.Save(projectConfigPath, projectConfig); err != nil { + return err + } + } + + for i := range projectConfig.AIWorkspaces { + normalizeAIWorkspaceProjectConfig(&projectConfig.AIWorkspaces[i]) + } + + // Resolve -o into either an explicit output file (when it ends in .json) or + // an output directory. With no -o, payloads land in the project build dir. + outputDir := filepath.Join(projectRoot, "build") + outputFile := "" + if trimmed := strings.TrimSpace(buildOutputDir); trimmed != "" { + resolved, err := filepath.Abs(trimmed) + if err != nil { + return fmt.Errorf("failed to resolve output path: %w", err) + } + if strings.EqualFold(filepath.Ext(resolved), ".json") { + outputFile = resolved + outputDir = filepath.Dir(resolved) + } else { + outputDir = resolved + } + } + + // An explicit output file can only hold a single payload. + if outputFile != "" && len(projectConfig.AIWorkspaces) > 1 { + return fmt.Errorf("output path %q is a file, but %d ai-workspace configurations are defined; use a directory instead", + buildOutputDir, len(projectConfig.AIWorkspaces)) + } + + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + outputs, failures := generateAIWorkspaceBuildArtifacts(projectRoot, outputDir, outputFile, buildUseSpec, projectConfig.AIWorkspaces) + + for _, output := range outputs { + fmt.Printf("AI workspace payload generated at %s\n", output) + } + + if len(failures) > 0 { + messages := make([]string, 0, len(failures)) + for _, failure := range failures { + fmt.Fprintf(os.Stderr, "AI workspace build failed for %q: %v\n", failure.name, failure.err) + messages = append(messages, failure.err.Error()) + } + return fmt.Errorf("failed to build %d of %d ai-workspace configuration(s): %s", + len(failures), len(projectConfig.AIWorkspaces), strings.Join(messages, "; ")) + } + + return nil +} + +func normalizeAIWorkspaceProjectConfig(config *project.AIWorkspaceConfig) { + if strings.TrimSpace(config.Name) == "" { + config.Name = "default" + } + if strings.TrimSpace(config.PortalRoot) == "" { + config.PortalRoot = "." + } + if strings.TrimSpace(config.FilePaths.Metadata) == "" { + config.FilePaths.Metadata = project.DefaultAIWorkspaceMetadata + } + if strings.TrimSpace(config.FilePaths.Runtime) == "" { + config.FilePaths.Runtime = project.DefaultAIWorkspaceRuntime + } + if strings.TrimSpace(config.FilePaths.Definition) == "" { + config.FilePaths.Definition = project.DefaultAIWorkspaceDefinition + } +} + +func generateAIWorkspaceBuildArtifacts(projectRoot, outputDir, outputFile string, useSpec bool, configs []project.AIWorkspaceConfig) ([]string, []failedWorkspace) { + outputs := make([]string, 0, len(configs)) + failures := make([]failedWorkspace, 0) + seen := make(map[string]string, len(configs)) // payload filename -> config name + + for i := range configs { + outputPath, err := buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile, useSpec, seen, &configs[i]) + if err != nil { + failures = append(failures, failedWorkspace{name: configs[i].Name, err: err}) + continue + } + outputs = append(outputs, outputPath) + } + + return outputs, failures +} + +// buildSingleAIWorkspacePayload reads the metadata.yaml and runtime.yaml for one +// ai-workspace config, derives the llm-proxy creation payload, optionally folds +// in the OpenAPI spec, and writes it as JSON. When outputFile is set it is +// written there; otherwise it lands at outputDir/.json. Any existing +// file is overwritten. Returning an error drops only this config. +func buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile string, useSpec bool, seen map[string]string, config *project.AIWorkspaceConfig) (string, error) { + baseDir := resolveProjectPath(projectRoot, config.PortalRoot) + if err := ensureWithinProjectRoot(projectRoot, baseDir, config.Name, "portalRoot"); err != nil { + return "", err + } + if err := ensurePathExists(baseDir, true, config.Name, "portalRoot"); err != nil { + return "", err + } + + metadataPath := resolveProjectPath(baseDir, config.FilePaths.Metadata) + runtimePath := resolveProjectPath(baseDir, config.FilePaths.Runtime) + + // metadata.yaml and runtime.yaml are the required inputs for the payload. + for _, required := range []struct { + label string + path string + }{ + {label: "metadata", path: metadataPath}, + {label: "runtime", path: runtimePath}, + } { + if err := ensureWithinProjectRoot(projectRoot, required.path, config.Name, required.label); err != nil { + return "", err + } + if err := ensurePathExists(required.path, false, config.Name, required.label); err != nil { + return "", err + } + } + + var metadata aiWorkspaceMetadata + if err := readYAMLFile(metadataPath, &metadata); err != nil { + return "", fmt.Errorf("ai-workspace config %q: failed to read metadata: %w", config.Name, err) + } + var runtime aiWorkspaceRuntime + if err := readYAMLFile(runtimePath, &runtime); err != nil { + return "", fmt.Errorf("ai-workspace config %q: failed to read runtime: %w", config.Name, err) + } + + proxyName := strings.TrimSpace(metadata.Metadata.Name) + if proxyName == "" { + return "", fmt.Errorf("ai-workspace config %q is invalid: metadata.metadata.name is required", config.Name) + } + + // The openapi field is left empty by default. It is populated only when the + // user opts in with --use-spec and the configured definition.yaml exists. + openapi := "" + if useSpec { + definitionPath := resolveProjectPath(baseDir, config.FilePaths.Definition) + if err := ensureWithinProjectRoot(projectRoot, definitionPath, config.Name, "definition"); err != nil { + return "", err + } + if info, err := os.Stat(definitionPath); err == nil && !info.IsDir() { + data, err := os.ReadFile(definitionPath) + if err != nil { + return "", fmt.Errorf("ai-workspace config %q: failed to read definition: %w", config.Name, err) + } + openapi = string(data) + } else if err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("ai-workspace config %q: failed to inspect definition: %w", config.Name, err) + } + } + + payload := buildLLMProxyPayload(proxyName, metadata, runtime, openapi) + + // An explicit -o file path wins; otherwise the artifact is named after the + // ai-workspace config name (not metadata.name) under the output directory, + // guarding against collisions. + outputPath := outputFile + if outputPath == "" { + fileName := payloadFileName(config.Name) + if existing, ok := seen[fileName]; ok { + return "", fmt.Errorf("payload file %q is already produced by config %q; rename one of the ai-workspace configurations to avoid overwriting the artifact", fileName, existing) + } + seen[fileName] = config.Name + outputPath = filepath.Join(outputDir, fileName) + } + + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return "", fmt.Errorf("ai-workspace config %q: failed to marshal payload: %w", config.Name, err) + } + if err := os.WriteFile(outputPath, append(data, '\n'), 0644); err != nil { + return "", fmt.Errorf("ai-workspace config %q: failed to write payload: %w", config.Name, err) + } + + return outputPath, nil +} + +// buildLLMProxyPayload assembles the createLLMProxy request body from the +// project's metadata.yaml (name/version) and runtime.yaml (context, provider, +// policies). projectId is intentionally omitted and vhost is left empty for the +// caller to fill in at publish time. +func buildLLMProxyPayload(proxyName string, metadata aiWorkspaceMetadata, runtime aiWorkspaceRuntime, openapi string) llmProxyPayload { + payload := llmProxyPayload{ + Name: proxyName, + Version: strings.TrimSpace(metadata.Spec.Version), + Context: strings.TrimSpace(runtime.Spec.Context), + Vhost: "", + OpenAPI: openapi, + Provider: llmProxyProvider{ID: strings.TrimSpace(runtime.Spec.Provider.ID)}, + Policies: []llmPolicy{}, + } + + if auth := runtime.Spec.Provider.Auth; auth != nil { + payload.Provider.Auth = &llmUpstreamAuth{ + Type: auth.Type, + Header: auth.Header, + Value: auth.Value, + } + } + + for _, policy := range runtime.Spec.Policies { + mapped := llmPolicy{ + Name: policy.Name, + Version: policy.Version, + Paths: make([]llmPolicyPath, 0, len(policy.Paths)), + } + for _, path := range policy.Paths { + mapped.Paths = append(mapped.Paths, llmPolicyPath{ + Path: path.Path, + Methods: path.Methods, + Params: path.Params, + }) + } + payload.Policies = append(payload.Policies, mapped) + } + + return payload +} + +func payloadFileName(name string) string { + sanitized := strings.TrimSpace(name) + sanitized = strings.ReplaceAll(sanitized, string(os.PathSeparator), "-") + sanitized = strings.ReplaceAll(sanitized, "/", "-") + sanitized = strings.ReplaceAll(sanitized, "\\", "-") + if sanitized == "" { + sanitized = "ai-workspace" + } + return sanitized + ".json" +} + +func readYAMLFile(path string, out interface{}) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := yaml.Unmarshal(data, out); err != nil { + return fmt.Errorf("failed to parse %s: %w", filepath.Base(path), err) + } + return nil +} + +// --- metadata.yaml / runtime.yaml input shapes (only the fields used here) --- + +type aiWorkspaceMetadata struct { + Metadata struct { + Name string `yaml:"name"` + } `yaml:"metadata"` + Spec struct { + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` + } `yaml:"spec"` +} + +type aiWorkspaceRuntime struct { + Metadata struct { + Name string `yaml:"name"` + } `yaml:"metadata"` + Spec struct { + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` + Context string `yaml:"context"` + Provider runtimeProvider `yaml:"provider"` + Policies []runtimeProviderPolicy `yaml:"policies"` + } `yaml:"spec"` +} + +type runtimeProvider struct { + ID string `yaml:"id"` + Auth *runtimeProviderAuth `yaml:"auth"` +} + +type runtimeProviderAuth struct { + Type string `yaml:"type"` + Header string `yaml:"header"` + Value string `yaml:"value"` +} + +type runtimeProviderPolicy struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Paths []runtimePolicyPath `yaml:"paths"` +} + +type runtimePolicyPath struct { + Path string `yaml:"path"` + Methods []string `yaml:"methods"` + Params map[string]interface{} `yaml:"params"` +} + +// --- createLLMProxy request body (subset; see openapi.yaml LLMProxy schema) --- + +type llmProxyPayload struct { + Name string `json:"name"` + Version string `json:"version"` + Context string `json:"context,omitempty"` + Vhost string `json:"vhost"` + Provider llmProxyProvider `json:"provider"` + OpenAPI string `json:"openapi"` + Policies []llmPolicy `json:"policies"` +} + +type llmProxyProvider struct { + ID string `json:"id"` + Auth *llmUpstreamAuth `json:"auth,omitempty"` +} + +type llmUpstreamAuth struct { + Type string `json:"type,omitempty"` + Header string `json:"header,omitempty"` + Value string `json:"value,omitempty"` +} + +type llmPolicy struct { + Name string `json:"name"` + Version string `json:"version"` + Paths []llmPolicyPath `json:"paths"` +} + +type llmPolicyPath struct { + Path string `json:"path"` + Methods []string `json:"methods"` + Params map[string]interface{} `json:"params"` +} + +// --- path helpers --- + +func resolveProjectPath(root, pathValue string) string { + trimmed := strings.TrimSpace(pathValue) + if trimmed == "" { + return root + } + + trimmed = strings.TrimPrefix(trimmed, "./") + return filepath.Join(root, filepath.Clean(trimmed)) +} + +// ensureWithinProjectRoot rejects resolved paths that escape the project root +// (e.g. via ".." segments or symlinks in a config value), keeping build inputs +// bounded to the project directory. +func ensureWithinProjectRoot(projectRoot, path, configName, fieldName string) error { + canonicalRoot, err := canonicalizePath(projectRoot) + if err != nil { + return fmt.Errorf("failed to resolve project root for ai-workspace config %q: %w", configName, err) + } + canonicalTarget, err := canonicalizePath(path) + if err != nil { + return fmt.Errorf("failed to resolve %s for ai-workspace config %q: %w", fieldName, configName, err) + } + + rel, err := filepath.Rel(canonicalRoot, canonicalTarget) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return fmt.Errorf("ai-workspace config %q is invalid: %s path resolves outside the project root: %s", configName, fieldName, path) + } + + return nil +} + +// canonicalizePath returns an absolute, symlink-resolved form of path so that +// containment checks are reliable across differing path forms. When the path +// does not yet exist, it resolves symlinks on the nearest existing ancestor and +// re-appends the remaining segments rather than failing. +func canonicalizePath(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + + remainder := "" + current := abs + for { + resolved, err := filepath.EvalSymlinks(current) + if err == nil { + if remainder == "" { + return resolved, nil + } + return filepath.Join(resolved, remainder), nil + } + if !os.IsNotExist(err) { + return "", err + } + + parent := filepath.Dir(current) + if parent == current { + return abs, nil + } + remainder = filepath.Join(filepath.Base(current), remainder) + current = parent + } +} + +func ensurePathExists(path string, wantDir bool, configName, fieldName string) error { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("ai-workspace config %q is invalid: %s path does not exist: %s", configName, fieldName, path) + } + return fmt.Errorf("failed to inspect %s for ai-workspace config %q: %w", fieldName, configName, err) + } + + if wantDir && !info.IsDir() { + return fmt.Errorf("ai-workspace config %q is invalid: %s must be a directory: %s", configName, fieldName, path) + } + if !wantDir && info.IsDir() { + return fmt.Errorf("ai-workspace config %q is invalid: %s must be a file: %s", configName, fieldName, path) + } + + return nil +} diff --git a/cli/src/utils/flags.go b/cli/src/utils/flags.go index d0d7c9c93c..404fdfc03f 100644 --- a/cli/src/utils/flags.go +++ b/cli/src/utils/flags.go @@ -80,6 +80,7 @@ const ( FlagBillingSubscriptionID = "billing-subscription-id" FlagReferenceID = "reference-id" FlagGatewayType = "gateway-type" + FlagUseSpec = "use-spec" ) var shortFlags = map[string]string{ From 8cf3706b23a4d3d8ae76e7788405a84665d952d3 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Thu, 18 Jun 2026 14:33:43 +0530 Subject: [PATCH 07/27] imorove aiws build command to support provider builder artifact --- cli/src/cmd/aiws/build.go | 548 +++++++++++++++++++++++++++++++++++--- 1 file changed, 512 insertions(+), 36 deletions(-) diff --git a/cli/src/cmd/aiws/build.go b/cli/src/cmd/aiws/build.go index 55b5029bcf..a2631ab3b2 100644 --- a/cli/src/cmd/aiws/build.go +++ b/cli/src/cmd/aiws/build.go @@ -23,6 +23,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "github.com/spf13/cobra" @@ -253,32 +254,43 @@ func buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile string, us return "", fmt.Errorf("ai-workspace config %q: failed to read runtime: %w", config.Name, err) } - proxyName := strings.TrimSpace(metadata.Metadata.Name) - if proxyName == "" { + // The kind declared in metadata.yaml and runtime.yaml must match. + metadataKind := strings.TrimSpace(metadata.Kind) + runtimeKind := strings.TrimSpace(runtime.Kind) + if metadataKind != runtimeKind { + return "", fmt.Errorf("ai-workspace config %q: kind mismatch: metadata.yaml has kind %q but runtime.yaml has kind %q", config.Name, metadataKind, runtimeKind) + } + + resourceName := strings.TrimSpace(metadata.Metadata.Name) + if resourceName == "" { return "", fmt.Errorf("ai-workspace config %q is invalid: metadata.metadata.name is required", config.Name) } - // The openapi field is left empty by default. It is populated only when the - // user opts in with --use-spec and the configured definition.yaml exists. - openapi := "" - if useSpec { - definitionPath := resolveProjectPath(baseDir, config.FilePaths.Definition) - if err := ensureWithinProjectRoot(projectRoot, definitionPath, config.Name, "definition"); err != nil { - return "", err - } - if info, err := os.Stat(definitionPath); err == nil && !info.IsDir() { - data, err := os.ReadFile(definitionPath) + // The payload shape and whether an OpenAPI spec is required are driven by the + // declared kind. An LlmProxy rarely needs a spec, so it stays opt-in via + // --use-spec; an LlmProvider always needs one, so the definition is required. + var payload interface{} + switch metadataKind { + case kindLLMProxy: + openapi := "" + if useSpec { + spec, err := loadAIWorkspaceSpec(projectRoot, baseDir, config, false) if err != nil { - return "", fmt.Errorf("ai-workspace config %q: failed to read definition: %w", config.Name, err) + return "", err } - openapi = string(data) - } else if err != nil && !os.IsNotExist(err) { - return "", fmt.Errorf("ai-workspace config %q: failed to inspect definition: %w", config.Name, err) + openapi = spec + } + payload = buildLLMProxyPayload(resourceName, metadata, runtime, openapi) + case kindLLMProvider: + openapi, err := loadAIWorkspaceSpec(projectRoot, baseDir, config, true) + if err != nil { + return "", err } + payload = buildLLMProviderPayload(resourceName, metadata, runtime, openapi) + default: + return "", fmt.Errorf("ai-workspace config %q: unsupported kind %q (supported: %s, %s)", config.Name, metadataKind, kindLLMProxy, kindLLMProvider) } - payload := buildLLMProxyPayload(proxyName, metadata, runtime, openapi) - // An explicit -o file path wins; otherwise the artifact is named after the // ai-workspace config name (not metadata.name) under the output directory, // guarding against collisions. @@ -303,6 +315,373 @@ func buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile string, us return outputPath, nil } +// Supported artifact kinds. These match the `kind` declared in metadata.yaml +// and runtime.yaml. +const ( + kindLLMProxy = "LlmProxy" + kindLLMProvider = "LlmProvider" +) + +// loadAIWorkspaceSpec reads the configured definition.yaml relative to baseDir +// and returns its content. When required is true a missing definition is an +// error; otherwise a missing definition yields an empty spec. +func loadAIWorkspaceSpec(projectRoot, baseDir string, config *project.AIWorkspaceConfig, required bool) (string, error) { + definitionPath := resolveProjectPath(baseDir, config.FilePaths.Definition) + if err := ensureWithinProjectRoot(projectRoot, definitionPath, config.Name, "definition"); err != nil { + return "", err + } + + info, err := os.Stat(definitionPath) + if err != nil { + if os.IsNotExist(err) { + if required { + return "", fmt.Errorf("ai-workspace config %q is invalid: definition path does not exist: %s", config.Name, definitionPath) + } + return "", nil + } + return "", fmt.Errorf("ai-workspace config %q: failed to inspect definition: %w", config.Name, err) + } + if info.IsDir() { + return "", fmt.Errorf("ai-workspace config %q is invalid: definition must be a file: %s", config.Name, definitionPath) + } + + data, err := os.ReadFile(definitionPath) + if err != nil { + return "", fmt.Errorf("ai-workspace config %q: failed to read definition: %w", config.Name, err) + } + return string(data), nil +} + +// buildLLMProviderPayload assembles the createLLMProvider request body from the +// project's metadata.yaml (name/version) and runtime.yaml (context, template, +// upstream, accessControl, policies). The api-key-auth policy is mapped to the +// security block. +// +// NOTE: modelProviders and rateLimiting are not yet populated (see the +// command's pending design questions) and are omitted from the payload. +func buildLLMProviderPayload(name string, metadata aiWorkspaceMetadata, runtime aiWorkspaceRuntime, openapi string) llmProviderPayload { + payload := llmProviderPayload{ + ID: name, + Name: name, + Version: strings.TrimSpace(metadata.Spec.Version), + Context: strings.TrimSpace(runtime.Spec.Context), + Template: strings.TrimSpace(runtime.Spec.Template), + OpenAPI: openapi, + } + + if up := runtime.Spec.Upstream; up != nil { + target := llmUpstreamTarget{URL: strings.TrimSpace(up.URL)} + if up.Auth != nil { + target.Auth = &llmUpstreamAuth{Type: up.Auth.Type, Header: up.Auth.Header, Value: up.Auth.Value} + } + payload.Upstream = &llmUpstream{Main: target} + } + + if ac := runtime.Spec.AccessControl; ac != nil { + mapped := &llmAccessControl{Mode: ac.Mode} + for _, exception := range ac.Exceptions { + mapped.Exceptions = append(mapped.Exceptions, routeException{Methods: exception.Methods, Path: exception.Path}) + } + payload.AccessControl = mapped + } + + payload.RateLimiting = buildRateLimitingFromPolicies(runtime.Spec.Policies) + payload.Security = buildSecurityFromPolicies(runtime.Spec.Policies) + + // Any policy that is not mapped to security (api-key-auth) or rateLimiting + // (*-ratelimit) passes through into the policies array unchanged. + for _, policy := range runtime.Spec.Policies { + if policy.Name == "api-key-auth" || strings.HasSuffix(policy.Name, "-ratelimit") { + continue + } + payload.Policies = append(payload.Policies, mapPolicy(policy)) + } + + return payload +} + +// mapPolicy converts a runtime.yaml policy into the payload policy shape. +func mapPolicy(policy runtimeProviderPolicy) llmPolicy { + mapped := llmPolicy{ + Name: policy.Name, + Version: policy.Version, + Paths: make([]llmPolicyPath, 0, len(policy.Paths)), + } + for _, path := range policy.Paths { + mapped.Paths = append(mapped.Paths, llmPolicyPath{ + Path: path.Path, + Methods: path.Methods, + Params: path.Params, + }) + } + return mapped +} + +// buildRateLimitingFromPolicies maps the *-ratelimit policies in runtime.yaml +// into the provider's rateLimiting block. The policy name selects the dimension +// (advanced-* -> request, token-based-* -> token, llm-cost-based-* -> cost) and +// the scope is consumer-level when the policy is flagged consumerBased (or, for +// advanced quotas, when the quota name carries a "consumer" prefix); otherwise +// it is provider-level. +// +// Each limit is applied globally when its path is "/*" and resource-wise (keyed +// by the path) otherwise. A scope that has any resource-wise limit is emitted as +// resourceWise, with any "/*" limits folded into its default. +func buildRateLimitingFromPolicies(policies []runtimeProviderPolicy) *llmRateLimiting { + provider := &scopeAccumulator{} + consumer := &scopeAccumulator{} + + for _, policy := range policies { + if !strings.HasSuffix(policy.Name, "-ratelimit") { + continue + } + for _, path := range policy.Paths { + params := path.Params + if params == nil { + continue + } + consumerBased := asBool(params["consumerBased"]) + + switch { + case strings.HasPrefix(policy.Name, "advanced"): + dimension, quotaConsumer := advancedRequestDimension(params) + if dimension == nil { + continue + } + scope := provider + if consumerBased || quotaConsumer { + scope = consumer + } + scope.configFor(path.Path).Request = dimension + case strings.Contains(policy.Name, "token"): + dimension := tokenDimension(params) + if dimension == nil { + continue + } + scope := provider + if consumerBased { + scope = consumer + } + scope.configFor(path.Path).Token = dimension + case strings.Contains(policy.Name, "cost"): + dimension := costDimension(params) + if dimension == nil { + continue + } + scope := provider + if consumerBased { + scope = consumer + } + scope.configFor(path.Path).Cost = dimension + } + } + } + + providerScope := provider.build() + consumerScope := consumer.build() + if providerScope == nil && consumerScope == nil { + return nil + } + return &llmRateLimiting{ProviderLevel: providerScope, ConsumerLevel: consumerScope} +} + +// scopeAccumulator collects rate-limit dimensions for one scope (provider or +// consumer), separating global ("/*") limits from per-path (resource-wise) ones. +type scopeAccumulator struct { + global *rateLimitConfig + resources map[string]*rateLimitConfig + order []string // preserves resource insertion order +} + +// configFor returns the limit config a dimension on path should be written to, +// creating it on first use. +func (a *scopeAccumulator) configFor(path string) *rateLimitConfig { + if path == "" || path == "/*" { + if a.global == nil { + a.global = &rateLimitConfig{} + } + return a.global + } + if a.resources == nil { + a.resources = map[string]*rateLimitConfig{} + } + if config, ok := a.resources[path]; ok { + return config + } + config := &rateLimitConfig{} + a.resources[path] = config + a.order = append(a.order, path) + return config +} + +// build renders the accumulator into a scope: global when only "/*" limits were +// seen, resourceWise (with "/*" limits as the default) when any path-specific +// limit was seen, or nil when empty. +func (a *scopeAccumulator) build() *rateLimitScope { + if len(a.order) == 0 { + if a.global == nil { + return nil + } + return &rateLimitScope{Global: a.global} + } + + defaultConfig := a.global + if defaultConfig == nil { + defaultConfig = &rateLimitConfig{} + } + resourceWise := &resourceWiseConfig{Default: defaultConfig} + for _, path := range a.order { + resourceWise.Resources = append(resourceWise.Resources, resourceLimit{Resource: path, Limit: a.resources[path]}) + } + return &rateLimitScope{ResourceWise: resourceWise} +} + +// advancedRequestDimension reads the first quota's first limit into a request +// dimension and reports whether the quota name marks it consumer-scoped. +func advancedRequestDimension(params map[string]interface{}) (*rateLimitDimension, bool) { + quota := firstMap(params["quotas"]) + if quota == nil { + return nil, false + } + isConsumer := strings.HasPrefix(asString(quota["name"]), "consumer") + limit := firstMap(quota["limits"]) + if limit == nil { + return nil, isConsumer + } + count, _ := asInt(limit["limit"]) + return &rateLimitDimension{ + Enabled: true, + Count: count, + Reset: parseResetWindow(asString(limit["duration"])), + }, isConsumer +} + +func tokenDimension(params map[string]interface{}) *rateLimitDimension { + limit := firstMap(params["totalTokenLimits"]) + if limit == nil { + return nil + } + count, _ := asInt(limit["count"]) + return &rateLimitDimension{ + Enabled: true, + Count: count, + Reset: parseResetWindow(asString(limit["duration"])), + } +} + +func costDimension(params map[string]interface{}) *rateLimitCostDimension { + limit := firstMap(params["budgetLimits"]) + if limit == nil { + return nil + } + amount, _ := asFloat(limit["amount"]) + return &rateLimitCostDimension{ + Enabled: true, + Amount: amount, + Reset: parseResetWindow(asString(limit["duration"])), + } +} + +// parseResetWindow turns a duration like "1h" or "3h" into a {duration, unit} +// reset window. Unknown unit suffixes are passed through unchanged. +func parseResetWindow(value string) *rateLimitReset { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + i := 0 + for i < len(value) && value[i] >= '0' && value[i] <= '9' { + i++ + } + if i == 0 { + return nil + } + duration, err := strconv.Atoi(value[:i]) + if err != nil { + return nil + } + unit := strings.ToLower(strings.TrimSpace(value[i:])) + switch unit { + case "m", "min", "minute", "minutes": + unit = "minute" + case "h", "hr", "hour", "hours": + unit = "hour" + case "d", "day", "days": + unit = "day" + case "w", "week", "weeks": + unit = "week" + case "mo", "month", "months": + unit = "month" + } + return &rateLimitReset{Duration: duration, Unit: unit} +} + +// --- free-form params accessors (runtime policy params are open JSON) --- + +func firstMap(value interface{}) map[string]interface{} { + slice, ok := value.([]interface{}) + if !ok || len(slice) == 0 { + return nil + } + m, _ := slice[0].(map[string]interface{}) + return m +} + +func asString(value interface{}) string { + s, _ := value.(string) + return s +} + +func asBool(value interface{}) bool { + b, _ := value.(bool) + return b +} + +func asInt(value interface{}) (int, bool) { + switch n := value.(type) { + case int: + return n, true + case int64: + return int(n), true + case float64: + return int(n), true + } + return 0, false +} + +func asFloat(value interface{}) (float64, bool) { + switch n := value.(type) { + case float64: + return n, true + case int: + return float64(n), true + case int64: + return float64(n), true + } + return 0, false +} + +// buildSecurityFromPolicies maps the api-key-auth policy (if present) to the +// provider's security block. +func buildSecurityFromPolicies(policies []runtimeProviderPolicy) *securityConfig { + for _, policy := range policies { + if policy.Name != "api-key-auth" { + continue + } + apiKey := &apiKeySecurity{Enabled: true} + for _, path := range policy.Paths { + if v, ok := path.Params["key"].(string); ok && apiKey.Key == "" { + apiKey.Key = v + } + if v, ok := path.Params["in"].(string); ok && apiKey.In == "" { + apiKey.In = v + } + } + return &securityConfig{Enabled: true, APIKey: apiKey} + } + return nil +} + // buildLLMProxyPayload assembles the createLLMProxy request body from the // project's metadata.yaml (name/version) and runtime.yaml (context, provider, // policies). projectId is intentionally omitted and vhost is left empty for the @@ -327,19 +706,7 @@ func buildLLMProxyPayload(proxyName string, metadata aiWorkspaceMetadata, runtim } for _, policy := range runtime.Spec.Policies { - mapped := llmPolicy{ - Name: policy.Name, - Version: policy.Version, - Paths: make([]llmPolicyPath, 0, len(policy.Paths)), - } - for _, path := range policy.Paths { - mapped.Paths = append(mapped.Paths, llmPolicyPath{ - Path: path.Path, - Methods: path.Methods, - Params: path.Params, - }) - } - payload.Policies = append(payload.Policies, mapped) + payload.Policies = append(payload.Policies, mapPolicy(policy)) } return payload @@ -370,6 +737,7 @@ func readYAMLFile(path string, out interface{}) error { // --- metadata.yaml / runtime.yaml input shapes (only the fields used here) --- type aiWorkspaceMetadata struct { + Kind string `yaml:"kind"` Metadata struct { Name string `yaml:"name"` } `yaml:"metadata"` @@ -380,15 +748,19 @@ type aiWorkspaceMetadata struct { } type aiWorkspaceRuntime struct { + Kind string `yaml:"kind"` Metadata struct { Name string `yaml:"name"` } `yaml:"metadata"` Spec struct { - DisplayName string `yaml:"displayName"` - Version string `yaml:"version"` - Context string `yaml:"context"` - Provider runtimeProvider `yaml:"provider"` - Policies []runtimeProviderPolicy `yaml:"policies"` + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` + Context string `yaml:"context"` + Template string `yaml:"template"` + Provider runtimeProvider `yaml:"provider"` + Upstream *runtimeUpstream `yaml:"upstream"` + AccessControl *runtimeAccessControl `yaml:"accessControl"` + Policies []runtimeProviderPolicy `yaml:"policies"` } `yaml:"spec"` } @@ -403,6 +775,21 @@ type runtimeProviderAuth struct { Value string `yaml:"value"` } +type runtimeUpstream struct { + URL string `yaml:"url"` + Auth *runtimeProviderAuth `yaml:"auth"` +} + +type runtimeAccessControl struct { + Mode string `yaml:"mode"` + Exceptions []runtimeRouteException `yaml:"exceptions"` +} + +type runtimeRouteException struct { + Methods []string `yaml:"methods"` + Path string `yaml:"path"` +} + type runtimeProviderPolicy struct { Name string `yaml:"name"` Version string `yaml:"version"` @@ -450,6 +837,95 @@ type llmPolicyPath struct { Params map[string]interface{} `json:"params"` } +// --- createLLMProvider request body (subset; see openapi.yaml LLMProvider schema) --- + +type llmProviderPayload struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Context string `json:"context,omitempty"` + Template string `json:"template"` + Upstream *llmUpstream `json:"upstream,omitempty"` + AccessControl *llmAccessControl `json:"accessControl,omitempty"` + OpenAPI string `json:"openapi"` + RateLimiting *llmRateLimiting `json:"rateLimiting,omitempty"` + Security *securityConfig `json:"security,omitempty"` + Policies []llmPolicy `json:"policies,omitempty"` +} + +type llmRateLimiting struct { + ProviderLevel *rateLimitScope `json:"providerLevel,omitempty"` + ConsumerLevel *rateLimitScope `json:"consumerLevel,omitempty"` +} + +type rateLimitScope struct { + Global *rateLimitConfig `json:"global,omitempty"` + ResourceWise *resourceWiseConfig `json:"resourceWise,omitempty"` +} + +type resourceWiseConfig struct { + Default *rateLimitConfig `json:"default,omitempty"` + Resources []resourceLimit `json:"resources"` +} + +type resourceLimit struct { + Resource string `json:"resource"` + Limit *rateLimitConfig `json:"limit,omitempty"` +} + +type rateLimitConfig struct { + Request *rateLimitDimension `json:"request,omitempty"` + Token *rateLimitDimension `json:"token,omitempty"` + Cost *rateLimitCostDimension `json:"cost,omitempty"` +} + +type rateLimitDimension struct { + Enabled bool `json:"enabled"` + Count int `json:"count"` + Reset *rateLimitReset `json:"reset,omitempty"` +} + +type rateLimitCostDimension struct { + Enabled bool `json:"enabled"` + Amount float64 `json:"amount"` + Reset *rateLimitReset `json:"reset,omitempty"` +} + +type rateLimitReset struct { + Duration int `json:"duration"` + Unit string `json:"unit"` +} + +type llmUpstream struct { + Main llmUpstreamTarget `json:"main"` +} + +type llmUpstreamTarget struct { + URL string `json:"url,omitempty"` + Auth *llmUpstreamAuth `json:"auth,omitempty"` +} + +type llmAccessControl struct { + Mode string `json:"mode"` + Exceptions []routeException `json:"exceptions,omitempty"` +} + +type routeException struct { + Methods []string `json:"methods"` + Path string `json:"path"` +} + +type securityConfig struct { + Enabled bool `json:"enabled"` + APIKey *apiKeySecurity `json:"apiKey,omitempty"` +} + +type apiKeySecurity struct { + Enabled bool `json:"enabled"` + Key string `json:"key,omitempty"` + In string `json:"in,omitempty"` +} + // --- path helpers --- func resolveProjectPath(root, pathValue string) string { From eb88613d7dd928c2177ec2ef10bf1e35ee003625 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Thu, 18 Jun 2026 14:40:04 +0530 Subject: [PATCH 08/27] add id generation --- cli/src/cmd/aiws/build.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/cli/src/cmd/aiws/build.go b/cli/src/cmd/aiws/build.go index a2631ab3b2..3dee0844c3 100644 --- a/cli/src/cmd/aiws/build.go +++ b/cli/src/cmd/aiws/build.go @@ -361,7 +361,7 @@ func loadAIWorkspaceSpec(projectRoot, baseDir string, config *project.AIWorkspac // command's pending design questions) and are omitted from the payload. func buildLLMProviderPayload(name string, metadata aiWorkspaceMetadata, runtime aiWorkspaceRuntime, openapi string) llmProviderPayload { payload := llmProviderPayload{ - ID: name, + ID: resourceID(metadata.Spec.DisplayName, name), Name: name, Version: strings.TrimSpace(metadata.Spec.Version), Context: strings.TrimSpace(runtime.Spec.Context), @@ -688,6 +688,7 @@ func buildSecurityFromPolicies(policies []runtimeProviderPolicy) *securityConfig // caller to fill in at publish time. func buildLLMProxyPayload(proxyName string, metadata aiWorkspaceMetadata, runtime aiWorkspaceRuntime, openapi string) llmProxyPayload { payload := llmProxyPayload{ + ID: resourceID(metadata.Spec.DisplayName, proxyName), Name: proxyName, Version: strings.TrimSpace(metadata.Spec.Version), Context: strings.TrimSpace(runtime.Spec.Context), @@ -712,6 +713,17 @@ func buildLLMProxyPayload(proxyName string, metadata aiWorkspaceMetadata, runtim return payload } +// resourceID derives the payload id from a display name: lowercased, with +// whitespace runs collapsed to single hyphens. Falls back to fallback when the +// display name is empty. +func resourceID(displayName, fallback string) string { + id := strings.Join(strings.Fields(strings.ToLower(displayName)), "-") + if id == "" { + return fallback + } + return id +} + func payloadFileName(name string) string { sanitized := strings.TrimSpace(name) sanitized = strings.ReplaceAll(sanitized, string(os.PathSeparator), "-") @@ -805,6 +817,7 @@ type runtimePolicyPath struct { // --- createLLMProxy request body (subset; see openapi.yaml LLMProxy schema) --- type llmProxyPayload struct { + ID string `json:"id"` Name string `json:"name"` Version string `json:"version"` Context string `json:"context,omitempty"` From 8d2669ad198165d3c6d11d0e90e0f17cc6be44a2 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Thu, 18 Jun 2026 15:56:44 +0530 Subject: [PATCH 09/27] add ai-ws related push commands --- cli/src/cmd/aiws/llmprovider/push.go | 119 ++++++++++++++++ cli/src/cmd/aiws/llmprovider/root.go | 44 ++++++ cli/src/cmd/aiws/llmproxy/push.go | 133 ++++++++++++++++++ cli/src/cmd/aiws/llmproxy/root.go | 44 ++++++ cli/src/cmd/aiws/root.go | 4 + cli/src/internal/aiworkspace/client.go | 172 ++++++++++++++++++++++++ cli/src/internal/aiworkspace/helpers.go | 161 ++++++++++++++++++++++ cli/src/utils/constants.go | 5 + cli/src/utils/flags.go | 1 + 9 files changed, 683 insertions(+) create mode 100644 cli/src/cmd/aiws/llmprovider/push.go create mode 100644 cli/src/cmd/aiws/llmprovider/root.go create mode 100644 cli/src/cmd/aiws/llmproxy/push.go create mode 100644 cli/src/cmd/aiws/llmproxy/root.go create mode 100644 cli/src/internal/aiworkspace/client.go create mode 100644 cli/src/internal/aiworkspace/helpers.go diff --git a/cli/src/cmd/aiws/llmprovider/push.go b/cli/src/cmd/aiws/llmprovider/push.go new file mode 100644 index 0000000000..dd6d05b8c4 --- /dev/null +++ b/cli/src/cmd/aiws/llmprovider/push.go @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmprovider + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + PushCmdLiteral = "push" + PushCmdExample = `# Push an LLM provider artifact using the active AI workspace +ap ai-ws llm-provider push -f build/wso2-claude.json --org + +# Push using a specific AI workspace +ap ai-ws llm-provider push -f build/wso2-claude.json --org --display-name my-workspace --platform eu` +) + +var ( + pushFilePath string + pushOrgID string + pushName string + pushPlatform string + pushInsecure bool +) + +var pushCmd = &cobra.Command{ + Use: PushCmdLiteral, + Short: "Push an LLM provider artifact to the AI workspace", + Long: "Push a generated LLM provider creation payload (JSON) to the WSO2 API Platform AI workspace.", + Example: PushCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runPushCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(pushCmd, utils.FlagFile, &pushFilePath, "", "Path to the LLM provider payload JSON file (required)") + utils.AddStringFlag(pushCmd, utils.FlagOrgID, &pushOrgID, "", "Organization ID (required)") + utils.AddStringFlag(pushCmd, utils.FlagName, &pushName, "", "AI workspace display name") + utils.AddStringFlag(pushCmd, utils.FlagPlatform, &pushPlatform, "", "Platform name") + pushCmd.Flags().BoolVar(&pushInsecure, "insecure", false, "Skip TLS certificate verification") + _ = pushCmd.MarkFlagRequired(utils.FlagFile) + _ = pushCmd.MarkFlagRequired(utils.FlagOrgID) +} + +func runPushCommand() error { + orgID := strings.TrimSpace(pushOrgID) + if orgID == "" { + return fmt.Errorf("organization ID is required") + } + + payload, err := aiworkspace.ReadJSONFile(pushFilePath) + if err != nil { + return err + } + + var meta struct { + ID string `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal(payload, &meta); err != nil { + return fmt.Errorf("failed to parse payload: %w", err) + } + providerID := strings.TrimSpace(meta.ID) + if providerID == "" { + providerName := strings.TrimSpace(meta.Name) + providerID = aiworkspace.ResourceID(providerName, "llm-provider") + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, pushName, pushPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, pushInsecure) + resp, err := client.PostJSON(aiworkspace.ProviderPath(orgID), payload) + if err != nil { + return aiworkspace.WrapRequestError("push llm provider", err, pushInsecure) + } + + if resp.StatusCode <= 200 || resp.StatusCode >= 300 { + return fmt.Errorf("failed to push llm provider, status: %s", resp.Status) + } + + fmt.Printf("LLM provider %q pushed to ai-workspace %s (platform: %s)\n", providerID, aiWorkspace.Name, resolvedPlatform) + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiws/llmprovider/root.go b/cli/src/cmd/aiws/llmprovider/root.go new file mode 100644 index 0000000000..72c073f178 --- /dev/null +++ b/cli/src/cmd/aiws/llmprovider/root.go @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmprovider + +import ( + "github.com/spf13/cobra" +) + +const ( + LLMProviderCmdLiteral = "llm-provider" + LLMProviderCmdExample = `# Push an LLM provider artifact to the AI workspace +ap ai-ws llm-provider push -f build/wso2-claude.json --org ` +) + +// LLMProviderCmd is the parent command for LLM provider operations. +var LLMProviderCmd = &cobra.Command{ + Use: LLMProviderCmdLiteral, + Short: "Manage LLM providers on the AI workspace", + Long: "This command allows you to manage LLM providers on the WSO2 API Platform AI workspace.", + Example: LLMProviderCmdExample, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + LLMProviderCmd.AddCommand(pushCmd) +} diff --git a/cli/src/cmd/aiws/llmproxy/push.go b/cli/src/cmd/aiws/llmproxy/push.go new file mode 100644 index 0000000000..54512eadf5 --- /dev/null +++ b/cli/src/cmd/aiws/llmproxy/push.go @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmproxy + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + PushCmdLiteral = "push" + PushCmdExample = `# Push an LLM proxy artifact using the active AI workspace +ap ai-ws llm-proxy push -f build/wso2-openai-proxy.json --org --project-id + +# Push using a specific AI workspace +ap ai-ws llm-proxy push -f build/wso2-openai-proxy.json --org --project-id --display-name my-workspace --platform eu` +) + +var ( + pushFilePath string + pushOrgID string + pushProjectID string + pushName string + pushPlatform string + pushInsecure bool +) + +var pushCmd = &cobra.Command{ + Use: PushCmdLiteral, + Short: "Push an LLM proxy artifact to the AI workspace", + Long: "Push a generated LLM proxy creation payload (JSON) to the WSO2 API Platform AI workspace. The supplied project ID is injected into the payload before it is sent.", + Example: PushCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runPushCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(pushCmd, utils.FlagFile, &pushFilePath, "", "Path to the LLM proxy payload JSON file (required)") + utils.AddStringFlag(pushCmd, utils.FlagOrgID, &pushOrgID, "", "Organization ID (required)") + utils.AddStringFlag(pushCmd, utils.FlagProjectID, &pushProjectID, "", "Project ID to set on the payload (required)") + utils.AddStringFlag(pushCmd, utils.FlagName, &pushName, "", "AI workspace display name") + utils.AddStringFlag(pushCmd, utils.FlagPlatform, &pushPlatform, "", "Platform name") + pushCmd.Flags().BoolVar(&pushInsecure, "insecure", false, "Skip TLS certificate verification") + _ = pushCmd.MarkFlagRequired(utils.FlagFile) + _ = pushCmd.MarkFlagRequired(utils.FlagOrgID) + _ = pushCmd.MarkFlagRequired(utils.FlagProjectID) +} + +func runPushCommand() error { + orgID := strings.TrimSpace(pushOrgID) + if orgID == "" { + return fmt.Errorf("organization ID is required") + } + projectID := strings.TrimSpace(pushProjectID) + if projectID == "" { + return fmt.Errorf("project ID is required") + } + + raw, err := aiworkspace.ReadJSONFile(pushFilePath) + if err != nil { + return err + } + + // Decode into a map so the project ID can be injected without dropping any + // fields the build emitted. + var payload map[string]interface{} + if err := json.Unmarshal(raw, &payload); err != nil { + return fmt.Errorf("failed to parse payload: %w", err) + } + + proxyID, _ := payload["id"].(string) + proxyID = strings.TrimSpace(proxyID) + if proxyID == "" { + return fmt.Errorf("payload is missing the required \"id\" field") + } + + payload["projectId"] = projectID + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to encode payload: %w", err) + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, pushName, pushPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, pushInsecure) + resp, err := client.PostJSON(aiworkspace.ProxyPath(orgID), body) + if err != nil { + return aiworkspace.WrapRequestError("push llm proxy", err, pushInsecure) + } + + if resp.StatusCode <= 200 || resp.StatusCode >= 300 { + return fmt.Errorf("failed to push llm proxy, status: %s", resp.Status) + } + + fmt.Printf("LLM proxy %q pushed to ai-workspace %s (platform: %s)\n", proxyID, aiWorkspace.Name, resolvedPlatform) + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiws/llmproxy/root.go b/cli/src/cmd/aiws/llmproxy/root.go new file mode 100644 index 0000000000..b684c6985e --- /dev/null +++ b/cli/src/cmd/aiws/llmproxy/root.go @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmproxy + +import ( + "github.com/spf13/cobra" +) + +const ( + LLMProxyCmdLiteral = "llm-proxy" + LLMProxyCmdExample = `# Push an LLM proxy artifact to the AI workspace +ap ai-ws llm-proxy push -f build/wso2-openai-proxy.json --org --project-id ` +) + +// LLMProxyCmd is the parent command for LLM proxy operations. +var LLMProxyCmd = &cobra.Command{ + Use: LLMProxyCmdLiteral, + Short: "Manage LLM proxies on the AI workspace", + Long: "This command allows you to manage LLM proxies on the WSO2 API Platform AI workspace.", + Example: LLMProxyCmdExample, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + LLMProxyCmd.AddCommand(pushCmd) +} diff --git a/cli/src/cmd/aiws/root.go b/cli/src/cmd/aiws/root.go index fc2e049dab..365d7bbbe3 100644 --- a/cli/src/cmd/aiws/root.go +++ b/cli/src/cmd/aiws/root.go @@ -20,6 +20,8 @@ package aiws import ( "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/cmd/aiws/llmprovider" + "github.com/wso2/api-platform/cli/cmd/aiws/llmproxy" ) const ( @@ -45,4 +47,6 @@ func init() { AiWSCmd.AddCommand(useCmd) AiWSCmd.AddCommand(currentCmd) AiWSCmd.AddCommand(buildCmd) + AiWSCmd.AddCommand(llmprovider.LLMProviderCmd) + AiWSCmd.AddCommand(llmproxy.LLMProxyCmd) } diff --git a/cli/src/internal/aiworkspace/client.go b/cli/src/internal/aiworkspace/client.go new file mode 100644 index 0000000000..1f7cf0c997 --- /dev/null +++ b/cli/src/internal/aiworkspace/client.go @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package aiworkspace holds the HTTP client used by the `ap ai-ws` commands to +// talk to an AI Workspace server (LLM proxies / providers). +package aiworkspace + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +type Client struct { + aiWorkspace *config.AIWorkspace + httpClient *http.Client +} + +type credCtxKey struct{} + +func NewClient(aiWorkspace *config.AIWorkspace) *Client { + return NewClientWithOptions(aiWorkspace, false) +} + +func NewClientWithOptions(aiWorkspace *config.AIWorkspace, insecure bool) *Client { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: insecure, + }, + } + + return &Client{ + aiWorkspace: aiWorkspace, + httpClient: &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + }, + } +} + +// PutJSON sends a JSON body with the PUT method. +func (c *Client) PutJSON(path string, body []byte) (*http.Response, error) { + return c.sendJSON(http.MethodPut, path, body) +} + +// PostJSON sends a JSON body with the POST method. +func (c *Client) PostJSON(path string, body []byte) (*http.Response, error) { + return c.sendJSON(http.MethodPost, path, body) +} + +func (c *Client) sendJSON(method, path string, body []byte) (*http.Response, error) { + baseURL := strings.TrimSuffix(c.aiWorkspace.URL, "/") + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + req, err := http.NewRequest(method, baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if baseURL != "" { + req.Header.Set("Origin", baseURL) + } + + resp, err := c.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return resp, nil + } + return nil, c.formatHTTPError(fmt.Sprintf("%s %s", method, path), resp) +} + +// Do attaches credentials based on the configured auth type (with environment +// variable overrides) and sends the request. +func (c *Client) Do(req *http.Request) (*http.Response, error) { + authType := c.aiWorkspace.Auth.Type + var credSource utils.CredentialSource + + switch authType { + case utils.AuthTypeBasic: + envUsername := os.Getenv(utils.EnvAIWorkspaceUsername) + envPassword := os.Getenv(utils.EnvAIWorkspacePassword) + if envUsername != "" && envPassword != "" { + req.SetBasicAuth(envUsername, envPassword) + credSource = utils.CredSourceEnv + } else { + if c.aiWorkspace.Auth.Username == "" || c.aiWorkspace.Auth.Password == "" { + return nil, c.missingCredsError(authType, utils.EnvAIWorkspaceUsername+" and "+utils.EnvAIWorkspacePassword) + } + req.SetBasicAuth(c.aiWorkspace.Auth.Username, c.aiWorkspace.Auth.Password) + credSource = utils.CredSourceConfig + } + + case utils.AuthTypeOAuth: + envToken := os.Getenv(utils.EnvAIWorkspaceToken) + if envToken != "" { + req.Header.Set("Authorization", "Bearer "+envToken) + credSource = utils.CredSourceEnv + } else { + if c.aiWorkspace.Auth.Token == "" { + return nil, c.missingCredsError(authType, utils.EnvAIWorkspaceToken) + } + req.Header.Set("Authorization", "Bearer "+c.aiWorkspace.Auth.Token) + credSource = utils.CredSourceConfig + } + + case utils.AuthTypeAPIKey: + envAPIKey := os.Getenv(utils.EnvAIWorkspaceAPIKey) + if envAPIKey != "" { + req.Header.Set(utils.AIWorkspaceAPIHeader, envAPIKey) + credSource = utils.CredSourceEnv + } else { + if c.aiWorkspace.Auth.APIKey == "" { + return nil, c.missingCredsError(authType, utils.EnvAIWorkspaceAPIKey) + } + req.Header.Set(utils.AIWorkspaceAPIHeader, c.aiWorkspace.Auth.APIKey) + credSource = utils.CredSourceConfig + } + + default: + return nil, fmt.Errorf("unsupported auth type '%s' for ai-workspace '%s'", authType, c.aiWorkspace.Name) + } + + req = req.WithContext(context.WithValue(req.Context(), credCtxKey{}, credSource)) + return c.httpClient.Do(req) +} + +func (c *Client) missingCredsError(authType, envVars string) error { + return fmt.Errorf("authentication credentials not found for ai-workspace '%s' (auth type: %s).\nPlease either:\n - Re-add ai-workspace: ap ai-ws add --display-name %s --server --auth %s\n - Or export: %s", + c.aiWorkspace.Name, authType, c.aiWorkspace.Name, authType, envVars) +} + +func (c *Client) formatHTTPError(operation string, resp *http.Response) error { + var credSource utils.CredentialSource + if resp != nil && resp.Request != nil { + if v := resp.Request.Context().Value(credCtxKey{}); v != nil { + if cs, ok := v.(utils.CredentialSource); ok { + credSource = cs + } + } + } + return utils.FormatHTTPErrorWithCredSource(operation, resp, "AI Workspace", c.aiWorkspace.Auth.Type, credSource, c.aiWorkspace.Name) +} diff --git a/cli/src/internal/aiworkspace/helpers.go b/cli/src/internal/aiworkspace/helpers.go new file mode 100644 index 0000000000..33a0ba9eda --- /dev/null +++ b/cli/src/internal/aiworkspace/helpers.go @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiworkspace + +import ( + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +// ResolveAIWorkspace resolves the AI Workspace to use from either explicit +// flags or the active AI Workspace in the resolved platform. +func ResolveAIWorkspace(cfg *config.Config, selectedName, selectedPlatform string) (*config.AIWorkspace, string, error) { + selectedName = strings.TrimSpace(selectedName) + selectedPlatform = strings.TrimSpace(selectedPlatform) + + if selectedName != "" { + resolvedPlatform := config.DefaultPlatform + if selectedPlatform != "" { + resolvedPlatform = cfg.ResolvePlatform(selectedPlatform) + } + aiWorkspace, err := cfg.GetAIWorkspaceFromPlatform(resolvedPlatform, selectedName) + if err != nil { + return nil, "", err + } + return aiWorkspace, resolvedPlatform, nil + } + + resolvedPlatform := cfg.ResolvePlatform(selectedPlatform) + aiWorkspace, err := cfg.GetActiveAIWorkspaceFromPlatform(resolvedPlatform) + if err != nil { + return nil, "", err + } + return aiWorkspace, resolvedPlatform, nil +} + +// ProviderPath builds the llm-providers resource path with the organizationId +// query parameter. +func ProviderPath(orgID string) string { + return withOrg(utils.AIWorkspaceLLMProvidersPath, orgID) +} + +// ProxyPath builds the llm-proxies resource path with the organizationId query +// parameter. +func ProxyPath(orgID string) string { + return withOrg(utils.AIWorkspaceLLMProxiesPath, orgID) +} + +func withOrg(path, orgID string) string { + return fmt.Sprintf("%s?organizationId=%s", path, url.QueryEscape(orgID)) +} + +// ReadJSONFile reads and validates a JSON payload file from disk. +func ReadJSONFile(filePath string) ([]byte, error) { + resolvedPath, err := filepath.Abs(strings.TrimSpace(filePath)) + if err != nil { + return nil, fmt.Errorf("failed to resolve file path: %w", err) + } + + info, err := os.Stat(resolvedPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("file not found: %s", resolvedPath) + } + return nil, fmt.Errorf("failed to inspect file: %w", err) + } + if info.IsDir() { + return nil, fmt.Errorf("file path must point to a JSON file, got directory: %s", resolvedPath) + } + + content, err := os.ReadFile(resolvedPath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + if !json.Valid(content) { + return nil, fmt.Errorf("file is not valid JSON: %s", resolvedPath) + } + return content, nil +} + +// WrapRequestError formats request failures and adds a hint about --insecure +// when the error is caused by certificate verification. +func WrapRequestError(action string, err error, insecure bool) error { + if shouldSuggestInsecure(err) && !insecure { + return fmt.Errorf("failed to %s: %w\nhint: retry with --insecure if you are using a self-signed or local development certificate", action, err) + } + return fmt.Errorf("failed to %s: %w", action, err) +} + +func shouldSuggestInsecure(err error) bool { + var unknownAuthorityErr x509.UnknownAuthorityError + var hostnameError x509.HostnameError + var certificateInvalidError x509.CertificateInvalidError + const certificateInvalidErrorString = "tls: failed to verify certificate: x509" + + return errors.As(err, &unknownAuthorityErr) || + errors.As(err, &hostnameError) || + errors.As(err, &certificateInvalidError) || + strings.Contains(err.Error(), certificateInvalidErrorString) +} + +// PrintJSONResponse prints an HTTP response as pretty JSON when possible and +// falls back to the raw trimmed body otherwise. +func PrintJSONResponse(resp *http.Response) error { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + trimmed := strings.TrimSpace(string(body)) + if trimmed == "" { + return nil + } + + var jsonBody interface{} + if err := json.Unmarshal(body, &jsonBody); err == nil { + if pretty, marshalErr := json.MarshalIndent(jsonBody, "", " "); marshalErr == nil { + fmt.Println(string(pretty)) + return nil + } + } + + fmt.Println(trimmed) + return nil +} + +func ResourceID(displayName, fallback string) string { + id := strings.Join(strings.Fields(strings.ToLower(displayName)), "-") + if id == "" { + return fallback + } + return id +} diff --git a/cli/src/utils/constants.go b/cli/src/utils/constants.go index e5a2c23089..a51988130c 100644 --- a/cli/src/utils/constants.go +++ b/cli/src/utils/constants.go @@ -81,6 +81,11 @@ const ( EnvAIWorkspacePassword = "WSO2AP_AIWORKSPACE_PASSWORD" // For AI workspace basic auth EnvAIWorkspaceToken = "WSO2AP_AIWORKSPACE_TOKEN" // For AI workspace OAuth auth EnvAIWorkspaceAPIKey = "WSO2AP_AIWORKSPACE_API_KEY" // For AI workspace API key auth + AIWorkspaceAPIHeader = "x-wso2-api-key" + + // AI Workspace REST API Endpoints (%s = resource id) + AIWorkspaceLLMProvidersPath = "/api-proxy/api/v1/llm-providers" + AIWorkspaceLLMProxiesPath = "/api-proxy/api/v1/llm-proxies" // Image Build Configuration GatewayVerifyChecksumOnBuild = true diff --git a/cli/src/utils/flags.go b/cli/src/utils/flags.go index 404fdfc03f..3da8a315eb 100644 --- a/cli/src/utils/flags.go +++ b/cli/src/utils/flags.go @@ -81,6 +81,7 @@ const ( FlagReferenceID = "reference-id" FlagGatewayType = "gateway-type" FlagUseSpec = "use-spec" + FlagProjectID = "project-id" ) var shortFlags = map[string]string{ From 5d98d54f2397358f544d86b4153cab77ae2076f1 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Thu, 18 Jun 2026 18:04:47 +0530 Subject: [PATCH 10/27] update aiws build command with mcp proxy --- cli/src/cmd/aiws/build.go | 127 +++++++++++++++++++++++++++++++++----- 1 file changed, 113 insertions(+), 14 deletions(-) diff --git a/cli/src/cmd/aiws/build.go b/cli/src/cmd/aiws/build.go index 3dee0844c3..19f0c0c59a 100644 --- a/cli/src/cmd/aiws/build.go +++ b/cli/src/cmd/aiws/build.go @@ -266,6 +266,11 @@ func buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile string, us return "", fmt.Errorf("ai-workspace config %q is invalid: metadata.metadata.name is required", config.Name) } + // metadata.name must match between metadata.yaml and runtime.yaml. + if runtimeName := strings.TrimSpace(runtime.Metadata.Name); runtimeName != resourceName { + return "", fmt.Errorf("ai-workspace config %q: name mismatch: metadata.yaml has metadata.name %q but runtime.yaml has metadata.name %q", config.Name, resourceName, runtimeName) + } + // The payload shape and whether an OpenAPI spec is required are driven by the // declared kind. An LlmProxy rarely needs a spec, so it stays opt-in via // --use-spec; an LlmProvider always needs one, so the definition is required. @@ -287,8 +292,19 @@ func buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile string, us return "", err } payload = buildLLMProviderPayload(resourceName, metadata, runtime, openapi) + case kindMCP: + // An MCP proxy always needs the definition (its capabilities live there). + spec, err := loadAIWorkspaceSpec(projectRoot, baseDir, config, true) + if err != nil { + return "", err + } + var definition mcpDefinition + if err := yaml.Unmarshal([]byte(spec), &definition); err != nil { + return "", fmt.Errorf("ai-workspace config %q: failed to parse definition: %w", config.Name, err) + } + payload = buildMCPProxyPayload(resourceName, metadata, runtime, definition) default: - return "", fmt.Errorf("ai-workspace config %q: unsupported kind %q (supported: %s, %s)", config.Name, metadataKind, kindLLMProxy, kindLLMProvider) + return "", fmt.Errorf("ai-workspace config %q: unsupported kind %q (supported: %s, %s, %s)", config.Name, metadataKind, kindLLMProxy, kindLLMProvider, kindMCP) } // An explicit -o file path wins; otherwise the artifact is named after the @@ -320,6 +336,7 @@ func buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile string, us const ( kindLLMProxy = "LlmProxy" kindLLMProvider = "LlmProvider" + kindMCP = "Mcp" ) // loadAIWorkspaceSpec reads the configured definition.yaml relative to baseDir @@ -361,7 +378,7 @@ func loadAIWorkspaceSpec(projectRoot, baseDir string, config *project.AIWorkspac // command's pending design questions) and are omitted from the payload. func buildLLMProviderPayload(name string, metadata aiWorkspaceMetadata, runtime aiWorkspaceRuntime, openapi string) llmProviderPayload { payload := llmProviderPayload{ - ID: resourceID(metadata.Spec.DisplayName, name), + ID: name, Name: name, Version: strings.TrimSpace(metadata.Spec.Version), Context: strings.TrimSpace(runtime.Spec.Context), @@ -417,6 +434,61 @@ func mapPolicy(policy runtimeProviderPolicy) llmPolicy { return mapped } +// buildMCPProxyPayload assembles the MCP proxy creation payload from the +// project's metadata.yaml (name/version), runtime.yaml (context, mcpSpecVersion, +// upstream, policies) and definition.yaml (capabilities). projectId is left out +// here and injected at publish time. +func buildMCPProxyPayload(name string, metadata aiWorkspaceMetadata, runtime aiWorkspaceRuntime, definition mcpDefinition) mcpProxyPayload { + payload := mcpProxyPayload{ + ID: name, + Name: name, + Version: strings.TrimSpace(metadata.Spec.Version), + Context: strings.TrimSpace(runtime.Spec.Context), + Description: "", + MCPSpecVersion: strings.TrimSpace(runtime.Spec.SpecVersion), + Capabilities: &mcpCapabilities{ + Prompts: definition.Prompts, + Resources: mcpResources(definition.Resources), + Tools: definition.Tools, + }, + } + + if up := runtime.Spec.Upstream; up != nil { + target := llmUpstreamTarget{URL: strings.TrimSpace(up.URL)} + if up.Auth != nil { + target.Auth = &llmUpstreamAuth{Type: up.Auth.Type, Header: up.Auth.Header, Value: up.Auth.Value} + } + payload.Upstream = &llmUpstream{Main: target} + } + + for _, policy := range runtime.Spec.Policies { + payload.Policies = append(payload.Policies, mcpPolicy{ + Name: policy.Name, + Version: policy.Version, + Params: policy.Params, + }) + } + + return payload +} + +// mcpResources strips each definition resource down to the fields the +// capabilities block carries (uri, name, mimeType), dropping inline text/blob +// content. +func mcpResources(resources []map[string]interface{}) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(resources)) + for _, resource := range resources { + trimmed := map[string]interface{}{} + for _, key := range []string{"uri", "name", "mimeType"} { + if value, ok := resource[key]; ok { + trimmed[key] = value + } + } + out = append(out, trimmed) + } + return out +} + // buildRateLimitingFromPolicies maps the *-ratelimit policies in runtime.yaml // into the provider's rateLimiting block. The policy name selects the dimension // (advanced-* -> request, token-based-* -> token, llm-cost-based-* -> cost) and @@ -688,7 +760,7 @@ func buildSecurityFromPolicies(policies []runtimeProviderPolicy) *securityConfig // caller to fill in at publish time. func buildLLMProxyPayload(proxyName string, metadata aiWorkspaceMetadata, runtime aiWorkspaceRuntime, openapi string) llmProxyPayload { payload := llmProxyPayload{ - ID: resourceID(metadata.Spec.DisplayName, proxyName), + ID: proxyName, Name: proxyName, Version: strings.TrimSpace(metadata.Spec.Version), Context: strings.TrimSpace(runtime.Spec.Context), @@ -713,17 +785,6 @@ func buildLLMProxyPayload(proxyName string, metadata aiWorkspaceMetadata, runtim return payload } -// resourceID derives the payload id from a display name: lowercased, with -// whitespace runs collapsed to single hyphens. Falls back to fallback when the -// display name is empty. -func resourceID(displayName, fallback string) string { - id := strings.Join(strings.Fields(strings.ToLower(displayName)), "-") - if id == "" { - return fallback - } - return id -} - func payloadFileName(name string) string { sanitized := strings.TrimSpace(name) sanitized = strings.ReplaceAll(sanitized, string(os.PathSeparator), "-") @@ -769,6 +830,7 @@ type aiWorkspaceRuntime struct { Version string `yaml:"version"` Context string `yaml:"context"` Template string `yaml:"template"` + SpecVersion string `yaml:"specVersion"` Provider runtimeProvider `yaml:"provider"` Upstream *runtimeUpstream `yaml:"upstream"` AccessControl *runtimeAccessControl `yaml:"accessControl"` @@ -806,6 +868,9 @@ type runtimeProviderPolicy struct { Name string `yaml:"name"` Version string `yaml:"version"` Paths []runtimePolicyPath `yaml:"paths"` + // Params holds policy-level params used by MCP policies (LLM proxy/provider + // policies carry their params under paths[].params instead). + Params map[string]interface{} `yaml:"params"` } type runtimePolicyPath struct { @@ -850,6 +915,40 @@ type llmPolicyPath struct { Params map[string]interface{} `json:"params"` } +// --- MCP definition input (definition.yaml) and MCP proxy request body --- + +// mcpDefinition is the parsed definition.yaml for an MCP proxy. Prompts and +// tools pass through verbatim; resources are trimmed before being emitted. +type mcpDefinition struct { + Prompts []map[string]interface{} `yaml:"prompts"` + Resources []map[string]interface{} `yaml:"resources"` + Tools []map[string]interface{} `yaml:"tools"` +} + +type mcpProxyPayload struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Context string `json:"context,omitempty"` + Description string `json:"description"` + MCPSpecVersion string `json:"mcpSpecVersion,omitempty"` + Upstream *llmUpstream `json:"upstream,omitempty"` + Capabilities *mcpCapabilities `json:"capabilities,omitempty"` + Policies []mcpPolicy `json:"policies,omitempty"` +} + +type mcpCapabilities struct { + Prompts []map[string]interface{} `json:"prompts"` + Resources []map[string]interface{} `json:"resources"` + Tools []map[string]interface{} `json:"tools"` +} + +type mcpPolicy struct { + Name string `json:"name"` + Version string `json:"version"` + Params map[string]interface{} `json:"params"` +} + // --- createLLMProvider request body (subset; see openapi.yaml LLMProvider schema) --- type llmProviderPayload struct { From cf75b81c67028fb36d04e38cd764b2c8adc1524e Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Thu, 18 Jun 2026 19:05:16 +0530 Subject: [PATCH 11/27] add edit commands for ai workspace artifacts --- cli/src/cmd/aiws/llmprovider/edit.go | 113 ++++++++ cli/src/cmd/aiws/llmprovider/push.go | 4 +- cli/src/cmd/aiws/llmprovider/root.go | 1 + cli/src/cmd/aiws/llmproxy/edit.go | 129 +++++++++ cli/src/cmd/aiws/llmproxy/push.go | 2 +- cli/src/cmd/aiws/llmproxy/root.go | 1 + cli/src/cmd/aiws/mcpproxy/edit.go | 129 +++++++++ cli/src/cmd/aiws/mcpproxy/push.go | 133 +++++++++ cli/src/cmd/aiws/mcpproxy/root.go | 45 +++ cli/src/cmd/aiws/root.go | 2 + cli/src/internal/aiworkspace/helpers.go | 24 ++ cli/src/internal/project/artifact.go | 2 +- cli/src/utils/constants.go | 3 +- docs/cli/README.md | 1 + docs/cli/ai-workspace/README.md | 369 ++++++++++++++++++++++++ 15 files changed, 953 insertions(+), 5 deletions(-) create mode 100644 cli/src/cmd/aiws/llmprovider/edit.go create mode 100644 cli/src/cmd/aiws/llmproxy/edit.go create mode 100644 cli/src/cmd/aiws/mcpproxy/edit.go create mode 100644 cli/src/cmd/aiws/mcpproxy/push.go create mode 100644 cli/src/cmd/aiws/mcpproxy/root.go create mode 100644 docs/cli/ai-workspace/README.md diff --git a/cli/src/cmd/aiws/llmprovider/edit.go b/cli/src/cmd/aiws/llmprovider/edit.go new file mode 100644 index 0000000000..ab796a6015 --- /dev/null +++ b/cli/src/cmd/aiws/llmprovider/edit.go @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmprovider + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + EditCmdLiteral = "edit" + EditCmdExample = `# Update an existing LLM provider using the active AI workspace +ap ai-ws llm-provider edit -f build/wso2-claude.json --org + +# Update using a specific AI workspace +ap ai-ws llm-provider edit -f build/wso2-claude.json --org --display-name my-workspace --platform eu` +) + +var ( + editFilePath string + editOrgID string + editName string + editPlatform string + editInsecure bool +) + +var editCmd = &cobra.Command{ + Use: EditCmdLiteral, + Short: "Update an existing LLM provider on the AI workspace", + Long: "Update an existing LLM provider on the WSO2 API Platform AI workspace by sending its JSON payload with a PUT request. The provider is identified by the \"id\" field in the payload.", + Example: EditCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runEditCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(editCmd, utils.FlagFile, &editFilePath, "", "Path to the LLM provider payload JSON file (required)") + utils.AddStringFlag(editCmd, utils.FlagOrgID, &editOrgID, "", "Organization ID (required)") + utils.AddStringFlag(editCmd, utils.FlagName, &editName, "", "AI workspace display name") + utils.AddStringFlag(editCmd, utils.FlagPlatform, &editPlatform, "", "Platform name") + editCmd.Flags().BoolVar(&editInsecure, "insecure", false, "Skip TLS certificate verification") + _ = editCmd.MarkFlagRequired(utils.FlagFile) + _ = editCmd.MarkFlagRequired(utils.FlagOrgID) +} + +func runEditCommand() error { + orgID := strings.TrimSpace(editOrgID) + if orgID == "" { + return fmt.Errorf("organization ID is required") + } + + payload, err := aiworkspace.ReadJSONFile(editFilePath) + if err != nil { + return err + } + + var meta struct { + ID string `json:"id"` + } + if err := json.Unmarshal(payload, &meta); err != nil { + return fmt.Errorf("failed to parse payload: %w", err) + } + providerID := strings.TrimSpace(meta.ID) + if providerID == "" { + return fmt.Errorf("payload is missing the required \"id\" field") + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, editName, editPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, editInsecure) + resp, err := client.PutJSON(aiworkspace.ProviderResourcePath(orgID, providerID), payload) + if err != nil { + return aiworkspace.WrapRequestError("update llm provider", err, editInsecure) + } + + fmt.Printf("LLM provider %q updated on ai-workspace %s (platform: %s)\n", providerID, aiWorkspace.Name, resolvedPlatform) + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiws/llmprovider/push.go b/cli/src/cmd/aiws/llmprovider/push.go index dd6d05b8c4..6e894d798b 100644 --- a/cli/src/cmd/aiws/llmprovider/push.go +++ b/cli/src/cmd/aiws/llmprovider/push.go @@ -82,7 +82,7 @@ func runPushCommand() error { } var meta struct { - ID string `json:"id"` + ID string `json:"id"` Name string `json:"name"` } if err := json.Unmarshal(payload, &meta); err != nil { @@ -110,7 +110,7 @@ func runPushCommand() error { return aiworkspace.WrapRequestError("push llm provider", err, pushInsecure) } - if resp.StatusCode <= 200 || resp.StatusCode >= 300 { + if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("failed to push llm provider, status: %s", resp.Status) } diff --git a/cli/src/cmd/aiws/llmprovider/root.go b/cli/src/cmd/aiws/llmprovider/root.go index 72c073f178..5ecb9fbcbe 100644 --- a/cli/src/cmd/aiws/llmprovider/root.go +++ b/cli/src/cmd/aiws/llmprovider/root.go @@ -41,4 +41,5 @@ var LLMProviderCmd = &cobra.Command{ func init() { LLMProviderCmd.AddCommand(pushCmd) + LLMProviderCmd.AddCommand(editCmd) } diff --git a/cli/src/cmd/aiws/llmproxy/edit.go b/cli/src/cmd/aiws/llmproxy/edit.go new file mode 100644 index 0000000000..9c23456a47 --- /dev/null +++ b/cli/src/cmd/aiws/llmproxy/edit.go @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmproxy + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + EditCmdLiteral = "edit" + EditCmdExample = `# Update an existing LLM proxy using the active AI workspace +ap ai-ws llm-proxy edit -f build/wso2-openai-proxy.json --org --project-id + +# Update using a specific AI workspace +ap ai-ws llm-proxy edit -f build/wso2-openai-proxy.json --org --project-id --display-name my-workspace --platform eu` +) + +var ( + editFilePath string + editOrgID string + editProjectID string + editName string + editPlatform string + editInsecure bool +) + +var editCmd = &cobra.Command{ + Use: EditCmdLiteral, + Short: "Update an existing LLM proxy on the AI workspace", + Long: "Update an existing LLM proxy on the WSO2 API Platform AI workspace by sending its JSON payload with a PUT request. The proxy is identified by the \"id\" field in the payload and the supplied project ID is injected into the payload before it is sent.", + Example: EditCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runEditCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(editCmd, utils.FlagFile, &editFilePath, "", "Path to the LLM proxy payload JSON file (required)") + utils.AddStringFlag(editCmd, utils.FlagOrgID, &editOrgID, "", "Organization ID (required)") + utils.AddStringFlag(editCmd, utils.FlagProjectID, &editProjectID, "", "Project ID to set on the payload (required)") + utils.AddStringFlag(editCmd, utils.FlagName, &editName, "", "AI workspace display name") + utils.AddStringFlag(editCmd, utils.FlagPlatform, &editPlatform, "", "Platform name") + editCmd.Flags().BoolVar(&editInsecure, "insecure", false, "Skip TLS certificate verification") + _ = editCmd.MarkFlagRequired(utils.FlagFile) + _ = editCmd.MarkFlagRequired(utils.FlagOrgID) + _ = editCmd.MarkFlagRequired(utils.FlagProjectID) +} + +func runEditCommand() error { + orgID := strings.TrimSpace(editOrgID) + if orgID == "" { + return fmt.Errorf("organization ID is required") + } + projectID := strings.TrimSpace(editProjectID) + if projectID == "" { + return fmt.Errorf("project ID is required") + } + + raw, err := aiworkspace.ReadJSONFile(editFilePath) + if err != nil { + return err + } + + // Decode into a map so the project ID can be injected without dropping any + // fields the build emitted. + var payload map[string]interface{} + if err := json.Unmarshal(raw, &payload); err != nil { + return fmt.Errorf("failed to parse payload: %w", err) + } + + proxyID, _ := payload["id"].(string) + proxyID = strings.TrimSpace(proxyID) + if proxyID == "" { + return fmt.Errorf("payload is missing the required \"id\" field") + } + + payload["projectId"] = projectID + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to encode payload: %w", err) + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, editName, editPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, editInsecure) + resp, err := client.PutJSON(aiworkspace.ProxyResourcePath(orgID, proxyID), body) + if err != nil { + return aiworkspace.WrapRequestError("update llm proxy", err, editInsecure) + } + + fmt.Printf("LLM proxy %q updated on ai-workspace %s (platform: %s)\n", proxyID, aiWorkspace.Name, resolvedPlatform) + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiws/llmproxy/push.go b/cli/src/cmd/aiws/llmproxy/push.go index 54512eadf5..18296b8ec0 100644 --- a/cli/src/cmd/aiws/llmproxy/push.go +++ b/cli/src/cmd/aiws/llmproxy/push.go @@ -124,7 +124,7 @@ func runPushCommand() error { return aiworkspace.WrapRequestError("push llm proxy", err, pushInsecure) } - if resp.StatusCode <= 200 || resp.StatusCode >= 300 { + if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("failed to push llm proxy, status: %s", resp.Status) } diff --git a/cli/src/cmd/aiws/llmproxy/root.go b/cli/src/cmd/aiws/llmproxy/root.go index b684c6985e..158d6eafa3 100644 --- a/cli/src/cmd/aiws/llmproxy/root.go +++ b/cli/src/cmd/aiws/llmproxy/root.go @@ -41,4 +41,5 @@ var LLMProxyCmd = &cobra.Command{ func init() { LLMProxyCmd.AddCommand(pushCmd) + LLMProxyCmd.AddCommand(editCmd) } diff --git a/cli/src/cmd/aiws/mcpproxy/edit.go b/cli/src/cmd/aiws/mcpproxy/edit.go new file mode 100644 index 0000000000..18a107a555 --- /dev/null +++ b/cli/src/cmd/aiws/mcpproxy/edit.go @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package mcpproxy + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + EditCmdLiteral = "edit" + EditCmdExample = `# Update an existing MCP proxy using the active AI workspace +ap ai-ws mcp-proxy edit -f build/bijira-mcp-everything.json --org --project-id + +# Update using a specific AI workspace +ap ai-ws mcp-proxy edit -f build/bijira-mcp-everything.json --org --project-id --display-name my-workspace --platform eu` +) + +var ( + editFilePath string + editOrgID string + editProjectID string + editName string + editPlatform string + editInsecure bool +) + +var editCmd = &cobra.Command{ + Use: EditCmdLiteral, + Short: "Update an existing MCP proxy on the AI workspace", + Long: "Update an existing MCP proxy on the WSO2 API Platform AI workspace by sending its JSON payload with a PUT request. The proxy is identified by the \"id\" field in the payload and the supplied project ID is injected into the payload before it is sent.", + Example: EditCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runEditCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(editCmd, utils.FlagFile, &editFilePath, "", "Path to the MCP proxy payload JSON file (required)") + utils.AddStringFlag(editCmd, utils.FlagOrgID, &editOrgID, "", "Organization ID (required)") + utils.AddStringFlag(editCmd, utils.FlagProjectID, &editProjectID, "", "Project ID to set on the payload (required)") + utils.AddStringFlag(editCmd, utils.FlagName, &editName, "", "AI workspace display name") + utils.AddStringFlag(editCmd, utils.FlagPlatform, &editPlatform, "", "Platform name") + editCmd.Flags().BoolVar(&editInsecure, "insecure", false, "Skip TLS certificate verification") + _ = editCmd.MarkFlagRequired(utils.FlagFile) + _ = editCmd.MarkFlagRequired(utils.FlagOrgID) + _ = editCmd.MarkFlagRequired(utils.FlagProjectID) +} + +func runEditCommand() error { + orgID := strings.TrimSpace(editOrgID) + if orgID == "" { + return fmt.Errorf("organization ID is required") + } + projectID := strings.TrimSpace(editProjectID) + if projectID == "" { + return fmt.Errorf("project ID is required") + } + + raw, err := aiworkspace.ReadJSONFile(editFilePath) + if err != nil { + return err + } + + // Decode into a map so the project ID can be injected without dropping any + // fields the build emitted. + var payload map[string]interface{} + if err := json.Unmarshal(raw, &payload); err != nil { + return fmt.Errorf("failed to parse payload: %w", err) + } + + proxyID, _ := payload["id"].(string) + proxyID = strings.TrimSpace(proxyID) + if proxyID == "" { + return fmt.Errorf("payload is missing the required \"id\" field") + } + + payload["projectId"] = projectID + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to encode payload: %w", err) + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, editName, editPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, editInsecure) + resp, err := client.PutJSON(aiworkspace.MCPProxyResourcePath(orgID, proxyID), body) + if err != nil { + return aiworkspace.WrapRequestError("update mcp proxy", err, editInsecure) + } + + fmt.Printf("MCP proxy %q updated on ai-workspace %s (platform: %s)\n", proxyID, aiWorkspace.Name, resolvedPlatform) + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiws/mcpproxy/push.go b/cli/src/cmd/aiws/mcpproxy/push.go new file mode 100644 index 0000000000..66e51dcf43 --- /dev/null +++ b/cli/src/cmd/aiws/mcpproxy/push.go @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package mcpproxy + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + PushCmdLiteral = "push" + PushCmdExample = `# Push an MCP proxy artifact using the active AI workspace +ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --org --project-id + +# Push using a specific AI workspace +ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --org --project-id --display-name my-workspace --platform eu` +) + +var ( + pushFilePath string + pushOrgID string + pushProjectID string + pushName string + pushPlatform string + pushInsecure bool +) + +var pushCmd = &cobra.Command{ + Use: PushCmdLiteral, + Short: "Push an MCP proxy artifact to the AI workspace", + Long: "Push a generated MCP proxy creation payload (JSON) to the WSO2 API Platform AI workspace. The supplied project ID is injected into the payload before it is sent.", + Example: PushCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runPushCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(pushCmd, utils.FlagFile, &pushFilePath, "", "Path to the MCP proxy payload JSON file (required)") + utils.AddStringFlag(pushCmd, utils.FlagOrgID, &pushOrgID, "", "Organization ID (required)") + utils.AddStringFlag(pushCmd, utils.FlagProjectID, &pushProjectID, "", "Project ID to set on the payload (required)") + utils.AddStringFlag(pushCmd, utils.FlagName, &pushName, "", "AI workspace display name") + utils.AddStringFlag(pushCmd, utils.FlagPlatform, &pushPlatform, "", "Platform name") + pushCmd.Flags().BoolVar(&pushInsecure, "insecure", false, "Skip TLS certificate verification") + _ = pushCmd.MarkFlagRequired(utils.FlagFile) + _ = pushCmd.MarkFlagRequired(utils.FlagOrgID) + _ = pushCmd.MarkFlagRequired(utils.FlagProjectID) +} + +func runPushCommand() error { + orgID := strings.TrimSpace(pushOrgID) + if orgID == "" { + return fmt.Errorf("organization ID is required") + } + projectID := strings.TrimSpace(pushProjectID) + if projectID == "" { + return fmt.Errorf("project ID is required") + } + + raw, err := aiworkspace.ReadJSONFile(pushFilePath) + if err != nil { + return err + } + + // Decode into a map so the project ID can be injected without dropping any + // fields the build emitted. + var payload map[string]interface{} + if err := json.Unmarshal(raw, &payload); err != nil { + return fmt.Errorf("failed to parse payload: %w", err) + } + + proxyID, _ := payload["id"].(string) + proxyID = strings.TrimSpace(proxyID) + if proxyID == "" { + return fmt.Errorf("payload is missing the required \"id\" field") + } + + payload["projectId"] = projectID + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to encode payload: %w", err) + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, pushName, pushPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, pushInsecure) + resp, err := client.PostJSON(aiworkspace.MCPProxyPath(orgID), body) + if err != nil { + return aiworkspace.WrapRequestError("push mcp proxy", err, pushInsecure) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("failed to push mcp proxy, status: %s", resp.Status) + } + + fmt.Printf("MCP proxy %q pushed to ai-workspace %s (platform: %s)\n", proxyID, aiWorkspace.Name, resolvedPlatform) + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiws/mcpproxy/root.go b/cli/src/cmd/aiws/mcpproxy/root.go new file mode 100644 index 0000000000..6babfd9813 --- /dev/null +++ b/cli/src/cmd/aiws/mcpproxy/root.go @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package mcpproxy + +import ( + "github.com/spf13/cobra" +) + +const ( + MCPProxyCmdLiteral = "mcp-proxy" + MCPProxyCmdExample = `# Push an MCP proxy artifact to the AI workspace +ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --org --project-id ` +) + +// MCPProxyCmd is the parent command for MCP proxy operations. +var MCPProxyCmd = &cobra.Command{ + Use: MCPProxyCmdLiteral, + Short: "Manage MCP proxies on the AI workspace", + Long: "This command allows you to manage MCP proxies on the WSO2 API Platform AI workspace.", + Example: MCPProxyCmdExample, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + MCPProxyCmd.AddCommand(pushCmd) + MCPProxyCmd.AddCommand(editCmd) +} diff --git a/cli/src/cmd/aiws/root.go b/cli/src/cmd/aiws/root.go index 365d7bbbe3..aeb3d1b6aa 100644 --- a/cli/src/cmd/aiws/root.go +++ b/cli/src/cmd/aiws/root.go @@ -22,6 +22,7 @@ import ( "github.com/spf13/cobra" "github.com/wso2/api-platform/cli/cmd/aiws/llmprovider" "github.com/wso2/api-platform/cli/cmd/aiws/llmproxy" + "github.com/wso2/api-platform/cli/cmd/aiws/mcpproxy" ) const ( @@ -49,4 +50,5 @@ func init() { AiWSCmd.AddCommand(buildCmd) AiWSCmd.AddCommand(llmprovider.LLMProviderCmd) AiWSCmd.AddCommand(llmproxy.LLMProxyCmd) + AiWSCmd.AddCommand(mcpproxy.MCPProxyCmd) } diff --git a/cli/src/internal/aiworkspace/helpers.go b/cli/src/internal/aiworkspace/helpers.go index 33a0ba9eda..8991576eeb 100644 --- a/cli/src/internal/aiworkspace/helpers.go +++ b/cli/src/internal/aiworkspace/helpers.go @@ -72,6 +72,30 @@ func ProxyPath(orgID string) string { return withOrg(utils.AIWorkspaceLLMProxiesPath, orgID) } +// MCPProxyPath builds the mcp-proxies resource path with the organizationId +// query parameter. +func MCPProxyPath(orgID string) string { + return withOrg(utils.AIWorkspaceMCPProxiesPath, orgID) +} + +// ProviderResourcePath builds the llm-providers/{id} resource path with the +// organizationId query parameter. +func ProviderResourcePath(orgID, id string) string { + return withOrg(utils.AIWorkspaceLLMProvidersPath+"/"+url.PathEscape(id), orgID) +} + +// ProxyResourcePath builds the llm-proxies/{id} resource path with the +// organizationId query parameter. +func ProxyResourcePath(orgID, id string) string { + return withOrg(utils.AIWorkspaceLLMProxiesPath+"/"+url.PathEscape(id), orgID) +} + +// MCPProxyResourcePath builds the mcp-proxies/{id} resource path with the +// organizationId query parameter. +func MCPProxyResourcePath(orgID, id string) string { + return withOrg(utils.AIWorkspaceMCPProxiesPath+"/"+url.PathEscape(id), orgID) +} + func withOrg(path, orgID string) string { return fmt.Sprintf("%s?organizationId=%s", path, url.QueryEscape(orgID)) } diff --git a/cli/src/internal/project/artifact.go b/cli/src/internal/project/artifact.go index c94e85c041..5a0e805c2e 100644 --- a/cli/src/internal/project/artifact.go +++ b/cli/src/internal/project/artifact.go @@ -79,7 +79,7 @@ func ArtifactKind(artifactType string) string { case utils.TypeLLMProviderTemplate: return "LlmProviderTemplate" case utils.TypeMCPProxy: - return "McpProxy" + return "Mcp" default: return "RestApi" } diff --git a/cli/src/utils/constants.go b/cli/src/utils/constants.go index a51988130c..93bcdd5783 100644 --- a/cli/src/utils/constants.go +++ b/cli/src/utils/constants.go @@ -83,9 +83,10 @@ const ( EnvAIWorkspaceAPIKey = "WSO2AP_AIWORKSPACE_API_KEY" // For AI workspace API key auth AIWorkspaceAPIHeader = "x-wso2-api-key" - // AI Workspace REST API Endpoints (%s = resource id) + // AI Workspace REST API Endpoints AIWorkspaceLLMProvidersPath = "/api-proxy/api/v1/llm-providers" AIWorkspaceLLMProxiesPath = "/api-proxy/api/v1/llm-proxies" + AIWorkspaceMCPProxiesPath = "/api-proxy/api/v1/mcp-proxies" // Image Build Configuration GatewayVerifyChecksumOnBuild = true diff --git a/docs/cli/README.md b/docs/cli/README.md index 304d20908f..0c121aff0d 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -9,3 +9,4 @@ | [Quick Start Guide](quick-start-guide.md) | Install the `ap` binary and configure environment variables to get up and running | | [CLI Reference](reference.md) | Full reference for all `ap gateway` sub-commands (add, list, apply, build, MCP, and more) | | [Customizing Gateway Policies](customizing-gateway-policies.md) | Build custom gateway images with local or PolicyHub policies using `ap gateway image build` | +| [AI-Workspace CLI Reference](ai-workspace/README.md) | Manage AI-Workspace connections (`add`, `list`, `use`, `current`, `remove`) and generate LLM proxy creation payloads with `ap ai-ws build` | diff --git a/docs/cli/ai-workspace/README.md b/docs/cli/ai-workspace/README.md new file mode 100644 index 0000000000..0f6c5fbb5f --- /dev/null +++ b/docs/cli/ai-workspace/README.md @@ -0,0 +1,369 @@ +# AI-Workspace CLI Reference + +This guide covers the AI-Workspace commands currently implemented under `cli/src/cmd/aiws`. + +Available command group: + +- `ap ai-ws` + +The `add`, `list`, `remove`, `use`, and `current` commands manage AI-Workspace **server connections** stored in the CLI config file (the same per-platform config used by `ap gateway` and `ap devportal`). The `build` command is different: it works inside an **API project** and generates an LLM proxy creation payload from the project's artifacts. + +## Prerequisites + +- Add at least one AI-Workspace configuration before using commands that target a specific workspace connection. +- AI-Workspace connections are stored in the CLI config file managed by `ap ai-ws add`. +- Commands that use an active AI-Workspace resolve the platform first, then the active AI-Workspace under that platform. + +## Authentication + +Supported AI-Workspace auth types: + +- `basic` +- `oauth` +- `api-key` (default) + +Environment variables override credentials stored in the CLI config. + +| Auth type | Environment variables | +| --- | --- | +| `basic` | `WSO2AP_AIWORKSPACE_USERNAME`, `WSO2AP_AIWORKSPACE_PASSWORD` | +| `oauth` | `WSO2AP_AIWORKSPACE_TOKEN` | +| `api-key` | `WSO2AP_AIWORKSPACE_API_KEY` | + +## Connection Notes + +- When a command accepts `--display-name` and `--platform`, it resolves the AI-Workspace explicitly. +- When `--display-name` is provided without `--platform`, the command looks in the `default` platform. +- When `--display-name` is not provided, the command uses the active AI-Workspace in the resolved platform. + +## Connection Commands + +### `ap ai-ws add` + +Adds an AI-Workspace configuration to the CLI config file. + +```shell +ap ai-ws add --display-name --server --auth [--platform ] [--no-interactive] +``` + +Examples: + +```shell +ap ai-ws add +ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth basic +ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth oauth +ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth api-key +ap ai-ws add --display-name my-workspace --platform eu --server https://ai-workspace.example.com --auth api-key --no-interactive +``` + +Notes: + +- Interactive mode prompts for missing values. +- Supplying credentials as flags is supported, but interactive mode or environment variables are preferred. +- If credentials are omitted, runtime commands expect the corresponding environment variables. + +### `ap ai-ws list` + +Lists AI-Workspace configurations for a platform. + +```shell +ap ai-ws list [--platform ] +``` + +Examples: + +```shell +ap ai-ws list +ap ai-ws list --platform eu +``` + +Notes: + +- If `--platform` is omitted, the current platform is used. +- The active AI-Workspace is marked in the output table. + +### `ap ai-ws remove` + +Removes an AI-Workspace configuration from a platform. + +```shell +ap ai-ws remove --display-name [--platform ] +``` + +Example: + +```shell +ap ai-ws remove --display-name my-workspace +``` + +### `ap ai-ws use` + +Sets the active AI-Workspace for a platform. + +```shell +ap ai-ws use --display-name [--platform ] +``` + +Examples: + +```shell +ap ai-ws use --display-name my-workspace +ap ai-ws use --display-name my-workspace --platform eu +``` + +Notes: + +- If `--platform` is omitted, the current platform is used. +- The command reports whether credentials will come from environment variables or the stored config. + +### `ap ai-ws current` + +Shows the active AI-Workspace for a platform. + +```shell +ap ai-ws current [--platform ] +``` + +Example: + +```shell +ap ai-ws current +``` + +## `ap ai-ws build` + +Generates an **LLM proxy**, **LLM provider**, or **MCP proxy** creation payload (JSON) from an API project's artifacts. The payload shape is selected by the `kind` declared in `metadata.yaml`/`runtime.yaml`: + +- `kind: LlmProxy` → matches the `POST /llm-proxies` request body (`LLMProxy` schema). +- `kind: LlmProvider` → matches the `POST /llm-providers` request body (`LLMProvider` schema). +- `kind: Mcp` → MCP proxy creation payload (capabilities + policies). + +Any other kind is rejected. + +```shell +ap ai-ws build [-f ] [-o ] [--use-spec] +``` + +Examples: + +```shell +# Build using the current directory as the project root +ap ai-ws build + +# Build from a specific project directory +ap ai-ws build -f /path/to/project + +# Write the payload to a specific directory +ap ai-ws build -o build/ + +# Write the payload to a specific file +ap ai-ws build -o build/openai.json + +# Fold the OpenAPI spec (definition.yaml) into the payload +ap ai-ws build --use-spec +``` + +### What it reads + +The command expects an API project containing `.api-platform/config.yaml`, and reads the `ai-workspaces` section of that file: + +```yaml +ai-workspaces: + - name: dev + portalRoot: . + filePaths: # paths relative to portalRoot + metadata: ./metadata.yaml + runtime: ./runtime.yaml + definition: ./definition.yaml # required for LlmProvider; opt-in for LlmProxy via --use-spec +``` + +For each configured entry, the build: + +- Resolves `metadata`, `runtime`, and `definition` relative to that entry's `portalRoot` (defaults: `./metadata.yaml`, `./runtime.yaml`, `./definition.yaml`; `portalRoot` defaults to `.`, the project root). +- Requires `metadata.yaml` and `runtime.yaml` to exist. +- Requires the `kind` declared in `metadata.yaml` and `runtime.yaml` to match; otherwise the build fails with a kind-mismatch error. +- Requires `metadata.name` to match between `metadata.yaml` and `runtime.yaml`; otherwise the build fails with a name-mismatch error. +- Handles `definition.yaml` (the OpenAPI spec) by kind — see [The OpenAPI spec](#the-openapi-spec---use-spec) below. +- If no `ai-workspaces` section exists, a single `default` entry (`portalRoot: .`) is created in the project config and used. + +All resolved paths are constrained to the project directory; a path that escapes the project root fails the build for that entry. + +### What it generates + +One JSON file per configured AI-Workspace entry, written to the build output. + +#### `LlmProxy` + +| Payload field | Source | +| --- | --- | +| `id` | `metadata.yaml` → `metadata.name` | +| `name` | `metadata.yaml` → `metadata.name` | +| `version` | `metadata.yaml` → `spec.version` | +| `context` | `runtime.yaml` → `spec.context` | +| `provider` (`id`, `auth.{type,header,value}`) | `runtime.yaml` → `spec.provider` | +| `policies[]` (`name`, `version`, `paths[].{path,methods,params}`) | `runtime.yaml` → `spec.policies` | +| `openapi` | content of `definition.yaml` with `--use-spec`, otherwise empty | +| `vhost` | always empty (filled in at publish time) | +| `projectId` | intentionally omitted | + +#### `LlmProvider` + +| Payload field | Source | +| --- | --- | +| `id` | `metadata.yaml` → `metadata.name` | +| `name` | `metadata.yaml` → `metadata.name` | +| `version` | `metadata.yaml` → `spec.version` | +| `context` | `runtime.yaml` → `spec.context` | +| `template` | `runtime.yaml` → `spec.template` | +| `upstream` (`main.{url,auth}`) | `runtime.yaml` → `spec.upstream` | +| `accessControl` (`mode`, `exceptions[]`) | `runtime.yaml` → `spec.accessControl` | +| `security` (`apiKey.{key,in}`) | the `api-key-auth` policy in `runtime.yaml` → `spec.policies` | +| `rateLimiting` | the `*-ratelimit` policies (see below) | +| `policies[]` (`name`, `version`, `paths[].{path,methods,params}`) | every other `runtime.yaml` → `spec.policies` entry (i.e. not `api-key-auth` or `*-ratelimit`) | +| `openapi` | content of `definition.yaml` (**required** for providers) | + +**rateLimiting mapping.** Each policy whose name ends with `-ratelimit` becomes a rate-limiting dimension, selected by name: + +| Policy name | Dimension | Value source (`params`) | +| --- | --- | --- | +| `advanced-ratelimit` | `request` | `quotas[].limits[].{limit,duration}` | +| `token-based-ratelimit` | `token` | `totalTokenLimits[].{count,duration}` | +| `llm-cost-based-ratelimit` | `cost` | `budgetLimits[].{amount,duration}` | + +Each dimension is placed under `consumerLevel` when the policy params carry `consumerBased: true` (or, for `advanced-ratelimit`, when the quota `name` starts with `consumer`); otherwise under `providerLevel`. Durations like `1h`/`3h` are parsed into `{duration, unit}` reset windows. + +A limit whose path is `/*` is applied as a `global` limit for its scope; a limit on a specific path is applied `resourceWise`, keyed by that path (with any `/*` limits in the same scope folded into the resourceWise `default`). + +> Not yet emitted: `modelProviders` (no source defined in the project artifacts yet). + +#### `Mcp` + +| Payload field | Source | +| --- | --- | +| `id` | `metadata.yaml` → `metadata.name` | +| `name` | `metadata.yaml` → `metadata.name` | +| `version` | `metadata.yaml` → `spec.version` | +| `context` | `runtime.yaml` → `spec.context` | +| `mcpSpecVersion` | `runtime.yaml` → `spec.specVersion` | +| `upstream` (`main.{url,auth}`) | `runtime.yaml` → `spec.upstream` | +| `policies[]` (`name`, `version`, `params`) | `runtime.yaml` → `spec.policies` (auth/authz/etc.) | +| `capabilities` (`prompts`, `resources`, `tools`) | `definition.yaml` (**required** for MCP) | +| `description` | empty | + +`definition.yaml` for an MCP proxy holds `prompts`, `resources`, and `tools`. `prompts` and `tools` are passed through unchanged; `resources` are trimmed to `uri`, `name`, and `mimeType` (any inline `text`/`blob` content is dropped). `projectId` is omitted and injected at publish time. + +### The OpenAPI spec (`--use-spec`) + +The `definition.yaml` is handled by kind: + +- **`LlmProvider`** — **required**: `definition.yaml` must exist (the build errors otherwise) and is always folded into `openapi`. `--use-spec` is not needed. +- **`Mcp`** — **required**: `definition.yaml` must exist; its `prompts`/`resources`/`tools` populate `capabilities`. `--use-spec` is not needed. +- **`LlmProxy`** — **opt-in**: `openapi` is empty by default even when `definition.yaml` exists; pass `--use-spec` to fold it in. A missing `definition.yaml` with `--use-spec` leaves the field empty (no error). + +### Output location and artifact names (`-o`) + +The artifact file name is taken from the **ai-workspace config `name`** in `.api-platform/config.yaml` (not from `metadata.name`). + +- **No `-o`** — payloads are written to the project `build/` directory as `build/.json`. +- **`-o `** (a path without a `.json` extension, e.g. `-o build/`) — payloads are written to that directory as `/.json`. Missing directories are created. +- **`-o `** (a path ending in `.json`) — the single payload is written to exactly that file. Missing parent directories are created. + +Behavior notes: + +- An existing output file is overwritten. +- A `.json` file target can only hold one payload, so `-o ` with **multiple** `ai-workspaces` configurations is an error — use a directory instead. +- With a directory target and multiple configurations, one file per config is produced (`.json`). + +## Push Commands + +These commands push a payload JSON (produced by `ap ai-ws build`) to the AI workspace server resolved from the CLI config (`--display-name`/`--platform`, or the active AI workspace). The resource `id` in the request URL is taken from the payload's `id` field, and credentials come from the configured auth type (see [Authentication](#authentication)). + +### `ap ai-ws llm-provider push` + +Creates/updates an LLM provider with `PUT /api-proxy/api/v1/llm-providers/{id}?organizationId={org}`. The JSON file is sent as the request body unchanged. + +```shell +ap ai-ws llm-provider push -f --org [--display-name ] [--platform ] [--insecure] +``` + +Examples: + +```shell +ap ai-ws llm-provider push -f build/wso2-claude.json --org 99089a17-72e0-4dd8-a2f4-c8dfbb085295 +ap ai-ws llm-provider push -f build/wso2-claude.json --org --display-name my-workspace --platform eu +``` + +### `ap ai-ws llm-proxy push` + +Creates an LLM proxy with `POST /api-proxy/api/v1/llm-proxies/{id}?organizationId={org}`. The supplied `--project-id` is injected into the payload as `projectId` before it is sent. + +```shell +ap ai-ws llm-proxy push -f --org --project-id [--display-name ] [--platform ] [--insecure] +``` + +Examples: + +```shell +ap ai-ws llm-proxy push -f build/wso2-openai-proxy.json --org --project-id 550e8400-e29b-41d4-a716-446655440000 +``` + +### `ap ai-ws mcp-proxy push` + +Creates an MCP proxy with `POST /api-proxy/api/v1/mcp-proxies?organizationId={org}`. Like the LLM proxy, the supplied `--project-id` is injected into the payload as `projectId` before it is sent. + +```shell +ap ai-ws mcp-proxy push -f --org --project-id [--display-name ] [--platform ] [--insecure] +``` + +Examples: + +```shell +ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --org --project-id 019ecf0e-8237-7153-96d5-bb3934e2c313 +``` + +Notes: + +- `--file` and `--org` are required for all push commands; `--project-id` is also required for LLM proxies and MCP proxies. +- `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. + +## Edit Commands + +These commands update an existing artifact on the AI workspace by sending its payload JSON with a `PUT` request to the id-scoped resource path (`/{resource}/{id}?organizationId={org}`). The resource `id` is taken from the payload's `id` field, and the AI workspace and credentials are resolved exactly like the push commands. + +### `ap ai-ws llm-provider edit` + +Updates an existing LLM provider with `PUT /api-proxy/api/v1/llm-providers/{id}?organizationId={org}`. The JSON file is sent as the request body unchanged. + +```shell +ap ai-ws llm-provider edit -f --org [--display-name ] [--platform ] [--insecure] +``` + +### `ap ai-ws llm-proxy edit` + +Updates an existing LLM proxy with `PUT /api-proxy/api/v1/llm-proxies/{id}?organizationId={org}`. The supplied `--project-id` is injected into the payload as `projectId` before it is sent. + +```shell +ap ai-ws llm-proxy edit -f --org --project-id [--display-name ] [--platform ] [--insecure] +``` + +### `ap ai-ws mcp-proxy edit` + +Updates an existing MCP proxy with `PUT /api-proxy/api/v1/mcp-proxies/{id}?organizationId={org}`. Like the LLM proxy, the supplied `--project-id` is injected into the payload as `projectId` before it is sent. + +```shell +ap ai-ws mcp-proxy edit -f --org --project-id [--display-name ] [--platform ] [--insecure] +``` + +Notes: + +- `--file` and `--org` are required for all edit commands; `--project-id` is also required for LLM proxies and MCP proxies. +- The payload must contain the `id` of the artifact to update; it identifies the resource in the request URL. +- `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. + +## Related Commands + +- `ap platform add` +- `ap platform use` +- `ap ai-ws use` +- `ap ai-ws build` +- `ap project init` From 26a1c56168074e840ad260ee6f44abf519e816c0 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Thu, 18 Jun 2026 20:29:50 +0530 Subject: [PATCH 12/27] update created output format --- cli/src/cmd/aiws/llmprovider/edit.go | 6 ++-- cli/src/cmd/aiws/llmprovider/push.go | 10 +++--- cli/src/cmd/aiws/llmproxy/edit.go | 6 ++-- cli/src/cmd/aiws/llmproxy/push.go | 10 +++--- cli/src/cmd/aiws/mcpproxy/edit.go | 6 ++-- cli/src/cmd/aiws/mcpproxy/push.go | 10 +++--- cli/src/internal/aiworkspace/helpers.go | 44 +++++++++++++++++++++++++ docs/cli/ai-workspace/README.md | 2 ++ 8 files changed, 70 insertions(+), 24 deletions(-) diff --git a/cli/src/cmd/aiws/llmprovider/edit.go b/cli/src/cmd/aiws/llmprovider/edit.go index ab796a6015..44c64a18dc 100644 --- a/cli/src/cmd/aiws/llmprovider/edit.go +++ b/cli/src/cmd/aiws/llmprovider/edit.go @@ -45,6 +45,7 @@ var ( editName string editPlatform string editInsecure bool + editOutput string ) var editCmd = &cobra.Command{ @@ -65,6 +66,7 @@ func init() { utils.AddStringFlag(editCmd, utils.FlagOrgID, &editOrgID, "", "Organization ID (required)") utils.AddStringFlag(editCmd, utils.FlagName, &editName, "", "AI workspace display name") utils.AddStringFlag(editCmd, utils.FlagPlatform, &editPlatform, "", "Platform name") + utils.AddStringFlag(editCmd, utils.FlagOutput, &editOutput, "", "Output format: \"json\" prints the full server response (default: summary)") editCmd.Flags().BoolVar(&editInsecure, "insecure", false, "Skip TLS certificate verification") _ = editCmd.MarkFlagRequired(utils.FlagFile) _ = editCmd.MarkFlagRequired(utils.FlagOrgID) @@ -108,6 +110,6 @@ func runEditCommand() error { return aiworkspace.WrapRequestError("update llm provider", err, editInsecure) } - fmt.Printf("LLM provider %q updated on ai-workspace %s (platform: %s)\n", providerID, aiWorkspace.Name, resolvedPlatform) - return aiworkspace.PrintJSONResponse(resp) + return aiworkspace.PrintArtifactResult(resp, editOutput, + fmt.Sprintf("LLM provider %q updated on ai-workspace %s (platform: %s)", providerID, aiWorkspace.Name, resolvedPlatform)) } diff --git a/cli/src/cmd/aiws/llmprovider/push.go b/cli/src/cmd/aiws/llmprovider/push.go index 6e894d798b..227e01832f 100644 --- a/cli/src/cmd/aiws/llmprovider/push.go +++ b/cli/src/cmd/aiws/llmprovider/push.go @@ -45,6 +45,7 @@ var ( pushName string pushPlatform string pushInsecure bool + pushOutput string ) var pushCmd = &cobra.Command{ @@ -65,6 +66,7 @@ func init() { utils.AddStringFlag(pushCmd, utils.FlagOrgID, &pushOrgID, "", "Organization ID (required)") utils.AddStringFlag(pushCmd, utils.FlagName, &pushName, "", "AI workspace display name") utils.AddStringFlag(pushCmd, utils.FlagPlatform, &pushPlatform, "", "Platform name") + utils.AddStringFlag(pushCmd, utils.FlagOutput, &pushOutput, "", "Output format: \"json\" prints the full server response (default: summary)") pushCmd.Flags().BoolVar(&pushInsecure, "insecure", false, "Skip TLS certificate verification") _ = pushCmd.MarkFlagRequired(utils.FlagFile) _ = pushCmd.MarkFlagRequired(utils.FlagOrgID) @@ -110,10 +112,6 @@ func runPushCommand() error { return aiworkspace.WrapRequestError("push llm provider", err, pushInsecure) } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("failed to push llm provider, status: %s", resp.Status) - } - - fmt.Printf("LLM provider %q pushed to ai-workspace %s (platform: %s)\n", providerID, aiWorkspace.Name, resolvedPlatform) - return aiworkspace.PrintJSONResponse(resp) + return aiworkspace.PrintArtifactResult(resp, pushOutput, + fmt.Sprintf("LLM provider %q pushed to ai-workspace %s (platform: %s)", providerID, aiWorkspace.Name, resolvedPlatform)) } diff --git a/cli/src/cmd/aiws/llmproxy/edit.go b/cli/src/cmd/aiws/llmproxy/edit.go index 9c23456a47..623c8304f0 100644 --- a/cli/src/cmd/aiws/llmproxy/edit.go +++ b/cli/src/cmd/aiws/llmproxy/edit.go @@ -46,6 +46,7 @@ var ( editName string editPlatform string editInsecure bool + editOutput string ) var editCmd = &cobra.Command{ @@ -67,6 +68,7 @@ func init() { utils.AddStringFlag(editCmd, utils.FlagProjectID, &editProjectID, "", "Project ID to set on the payload (required)") utils.AddStringFlag(editCmd, utils.FlagName, &editName, "", "AI workspace display name") utils.AddStringFlag(editCmd, utils.FlagPlatform, &editPlatform, "", "Platform name") + utils.AddStringFlag(editCmd, utils.FlagOutput, &editOutput, "", "Output format: \"json\" prints the full server response (default: summary)") editCmd.Flags().BoolVar(&editInsecure, "insecure", false, "Skip TLS certificate verification") _ = editCmd.MarkFlagRequired(utils.FlagFile) _ = editCmd.MarkFlagRequired(utils.FlagOrgID) @@ -124,6 +126,6 @@ func runEditCommand() error { return aiworkspace.WrapRequestError("update llm proxy", err, editInsecure) } - fmt.Printf("LLM proxy %q updated on ai-workspace %s (platform: %s)\n", proxyID, aiWorkspace.Name, resolvedPlatform) - return aiworkspace.PrintJSONResponse(resp) + return aiworkspace.PrintArtifactResult(resp, editOutput, + fmt.Sprintf("LLM proxy %q updated on ai-workspace %s (platform: %s)", proxyID, aiWorkspace.Name, resolvedPlatform)) } diff --git a/cli/src/cmd/aiws/llmproxy/push.go b/cli/src/cmd/aiws/llmproxy/push.go index 18296b8ec0..575e00f3d9 100644 --- a/cli/src/cmd/aiws/llmproxy/push.go +++ b/cli/src/cmd/aiws/llmproxy/push.go @@ -46,6 +46,7 @@ var ( pushName string pushPlatform string pushInsecure bool + pushOutput string ) var pushCmd = &cobra.Command{ @@ -67,6 +68,7 @@ func init() { utils.AddStringFlag(pushCmd, utils.FlagProjectID, &pushProjectID, "", "Project ID to set on the payload (required)") utils.AddStringFlag(pushCmd, utils.FlagName, &pushName, "", "AI workspace display name") utils.AddStringFlag(pushCmd, utils.FlagPlatform, &pushPlatform, "", "Platform name") + utils.AddStringFlag(pushCmd, utils.FlagOutput, &pushOutput, "", "Output format: \"json\" prints the full server response (default: summary)") pushCmd.Flags().BoolVar(&pushInsecure, "insecure", false, "Skip TLS certificate verification") _ = pushCmd.MarkFlagRequired(utils.FlagFile) _ = pushCmd.MarkFlagRequired(utils.FlagOrgID) @@ -124,10 +126,6 @@ func runPushCommand() error { return aiworkspace.WrapRequestError("push llm proxy", err, pushInsecure) } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("failed to push llm proxy, status: %s", resp.Status) - } - - fmt.Printf("LLM proxy %q pushed to ai-workspace %s (platform: %s)\n", proxyID, aiWorkspace.Name, resolvedPlatform) - return aiworkspace.PrintJSONResponse(resp) + return aiworkspace.PrintArtifactResult(resp, pushOutput, + fmt.Sprintf("LLM proxy %q pushed to ai-workspace %s (platform: %s)", proxyID, aiWorkspace.Name, resolvedPlatform)) } diff --git a/cli/src/cmd/aiws/mcpproxy/edit.go b/cli/src/cmd/aiws/mcpproxy/edit.go index 18a107a555..df4b684b8c 100644 --- a/cli/src/cmd/aiws/mcpproxy/edit.go +++ b/cli/src/cmd/aiws/mcpproxy/edit.go @@ -46,6 +46,7 @@ var ( editName string editPlatform string editInsecure bool + editOutput string ) var editCmd = &cobra.Command{ @@ -67,6 +68,7 @@ func init() { utils.AddStringFlag(editCmd, utils.FlagProjectID, &editProjectID, "", "Project ID to set on the payload (required)") utils.AddStringFlag(editCmd, utils.FlagName, &editName, "", "AI workspace display name") utils.AddStringFlag(editCmd, utils.FlagPlatform, &editPlatform, "", "Platform name") + utils.AddStringFlag(editCmd, utils.FlagOutput, &editOutput, "", "Output format: \"json\" prints the full server response (default: summary)") editCmd.Flags().BoolVar(&editInsecure, "insecure", false, "Skip TLS certificate verification") _ = editCmd.MarkFlagRequired(utils.FlagFile) _ = editCmd.MarkFlagRequired(utils.FlagOrgID) @@ -124,6 +126,6 @@ func runEditCommand() error { return aiworkspace.WrapRequestError("update mcp proxy", err, editInsecure) } - fmt.Printf("MCP proxy %q updated on ai-workspace %s (platform: %s)\n", proxyID, aiWorkspace.Name, resolvedPlatform) - return aiworkspace.PrintJSONResponse(resp) + return aiworkspace.PrintArtifactResult(resp, editOutput, + fmt.Sprintf("MCP proxy %q updated on ai-workspace %s (platform: %s)", proxyID, aiWorkspace.Name, resolvedPlatform)) } diff --git a/cli/src/cmd/aiws/mcpproxy/push.go b/cli/src/cmd/aiws/mcpproxy/push.go index 66e51dcf43..ffccbb7846 100644 --- a/cli/src/cmd/aiws/mcpproxy/push.go +++ b/cli/src/cmd/aiws/mcpproxy/push.go @@ -46,6 +46,7 @@ var ( pushName string pushPlatform string pushInsecure bool + pushOutput string ) var pushCmd = &cobra.Command{ @@ -67,6 +68,7 @@ func init() { utils.AddStringFlag(pushCmd, utils.FlagProjectID, &pushProjectID, "", "Project ID to set on the payload (required)") utils.AddStringFlag(pushCmd, utils.FlagName, &pushName, "", "AI workspace display name") utils.AddStringFlag(pushCmd, utils.FlagPlatform, &pushPlatform, "", "Platform name") + utils.AddStringFlag(pushCmd, utils.FlagOutput, &pushOutput, "", "Output format: \"json\" prints the full server response (default: summary)") pushCmd.Flags().BoolVar(&pushInsecure, "insecure", false, "Skip TLS certificate verification") _ = pushCmd.MarkFlagRequired(utils.FlagFile) _ = pushCmd.MarkFlagRequired(utils.FlagOrgID) @@ -124,10 +126,6 @@ func runPushCommand() error { return aiworkspace.WrapRequestError("push mcp proxy", err, pushInsecure) } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("failed to push mcp proxy, status: %s", resp.Status) - } - - fmt.Printf("MCP proxy %q pushed to ai-workspace %s (platform: %s)\n", proxyID, aiWorkspace.Name, resolvedPlatform) - return aiworkspace.PrintJSONResponse(resp) + return aiworkspace.PrintArtifactResult(resp, pushOutput, + fmt.Sprintf("MCP proxy %q pushed to ai-workspace %s (platform: %s)", proxyID, aiWorkspace.Name, resolvedPlatform)) } diff --git a/cli/src/internal/aiworkspace/helpers.go b/cli/src/internal/aiworkspace/helpers.go index 8991576eeb..a4df250da2 100644 --- a/cli/src/internal/aiworkspace/helpers.go +++ b/cli/src/internal/aiworkspace/helpers.go @@ -149,6 +149,50 @@ func shouldSuggestInsecure(err error) bool { strings.Contains(err.Error(), certificateInvalidErrorString) } +// OutputJSON reports whether the requested output format is the full JSON body. +func OutputJSON(format string) bool { + return strings.EqualFold(strings.TrimSpace(format), "json") +} + +// PrintArtifactResult prints the result of a create/edit operation. By default +// it prints only the concise summary line (the server-returned artifact is +// drained and discarded so it does not clutter the terminal). When the output +// format is "json" the full response body is pretty-printed instead, so the +// command stays scriptable (e.g. `... -o json | jq`). +func PrintArtifactResult(resp *http.Response, outputFormat, summary string) error { + if OutputJSON(outputFormat) { + return PrintJSONResponse(resp) + } + + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if extra := summarizeArtifact(body); extra != "" { + summary += " " + extra + } + fmt.Println(summary) + return nil +} + +// summarizeArtifact extracts a few server-assigned fields worth surfacing in the +// summary line. It returns an empty string when none are present. +func summarizeArtifact(body []byte) string { + var m map[string]interface{} + if err := json.Unmarshal(body, &m); err != nil { + return "" + } + var parts []string + if v, ok := m["version"].(string); ok && strings.TrimSpace(v) != "" { + parts = append(parts, "version: "+v) + } + if s, ok := m["status"].(string); ok && strings.TrimSpace(s) != "" { + parts = append(parts, "status: "+s) + } + if len(parts) == 0 { + return "" + } + return "(" + strings.Join(parts, ", ") + ")" +} + // PrintJSONResponse prints an HTTP response as pretty JSON when possible and // falls back to the raw trimmed body otherwise. func PrintJSONResponse(resp *http.Response) error { diff --git a/docs/cli/ai-workspace/README.md b/docs/cli/ai-workspace/README.md index 0f6c5fbb5f..735a479a9b 100644 --- a/docs/cli/ai-workspace/README.md +++ b/docs/cli/ai-workspace/README.md @@ -324,6 +324,7 @@ ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --org --pro Notes: - `--file` and `--org` are required for all push commands; `--project-id` is also required for LLM proxies and MCP proxies. +- By default only a concise summary line is printed. Pass `--output json` (or `-o json`) to print the full server response (useful for piping to `jq`). - `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. ## Edit Commands @@ -358,6 +359,7 @@ Notes: - `--file` and `--org` are required for all edit commands; `--project-id` is also required for LLM proxies and MCP proxies. - The payload must contain the `id` of the artifact to update; it identifies the resource in the request URL. +- By default only a concise summary line is printed. Pass `--output json` (or `-o json`) to print the full server response (useful for piping to `jq`). - `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. ## Related Commands From ba3c87ade492e85339279a95c65d21698766b426 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Fri, 19 Jun 2026 09:34:18 +0530 Subject: [PATCH 13/27] add ai workspace related get comamnds, improve update commands --- cli/src/cmd/aiws/llmprovider/get.go | 111 ++++++++++++++++++++++++ cli/src/cmd/aiws/llmprovider/root.go | 1 + cli/src/cmd/aiws/llmproxy/edit.go | 2 +- cli/src/cmd/aiws/llmproxy/get.go | 110 +++++++++++++++++++++++ cli/src/cmd/aiws/llmproxy/root.go | 1 + cli/src/cmd/aiws/mcpproxy/get.go | 110 +++++++++++++++++++++++ cli/src/cmd/aiws/mcpproxy/root.go | 1 + cli/src/internal/aiworkspace/client.go | 27 ++++++ cli/src/internal/aiworkspace/helpers.go | 58 +++++++++++++ cli/src/utils/flags.go | 2 + 10 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 cli/src/cmd/aiws/llmprovider/get.go create mode 100644 cli/src/cmd/aiws/llmproxy/get.go create mode 100644 cli/src/cmd/aiws/mcpproxy/get.go diff --git a/cli/src/cmd/aiws/llmprovider/get.go b/cli/src/cmd/aiws/llmprovider/get.go new file mode 100644 index 0000000000..67799b3557 --- /dev/null +++ b/cli/src/cmd/aiws/llmprovider/get.go @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmprovider + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + GetCmdLiteral = "get" + GetCmdExample = `# List all LLM providers in an organization +ap ai-ws llm-provider get --org + +# Get a single LLM provider by ID +ap ai-ws llm-provider get --org --id wso2-claude + +# List using a specific AI workspace with pagination +ap ai-ws llm-provider get --org --limit 50 --offset 0 --display-name my-workspace --platform eu` +) + +var ( + getID string + getOrgID string + getLimit string + getOffset string + getName string + getPlatform string + getInsecure bool +) + +var getCmd = &cobra.Command{ + Use: GetCmdLiteral, + Short: "Get one or all LLM providers from the AI workspace", + Long: "Retrieve LLM providers from the WSO2 API Platform AI workspace. With --id a single provider is fetched; without it all providers in the organization are listed.", + Example: GetCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runGetCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(getCmd, utils.FlagID, &getID, "", "LLM provider ID (omit to list all)") + utils.AddStringFlag(getCmd, utils.FlagOrgID, &getOrgID, "", "Organization ID (required)") + utils.AddStringFlag(getCmd, utils.FlagLimit, &getLimit, "", "Maximum number of providers to return when listing") + utils.AddStringFlag(getCmd, utils.FlagOffset, &getOffset, "", "Number of providers to skip when listing") + utils.AddStringFlag(getCmd, utils.FlagName, &getName, "", "AI workspace display name") + utils.AddStringFlag(getCmd, utils.FlagPlatform, &getPlatform, "", "Platform name") + getCmd.Flags().BoolVar(&getInsecure, "insecure", false, "Skip TLS certificate verification") + _ = getCmd.MarkFlagRequired(utils.FlagOrgID) +} + +func runGetCommand() error { + orgID := strings.TrimSpace(getOrgID) + if orgID == "" { + return fmt.Errorf("organization ID is required") + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, getName, getPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, getInsecure) + + var path, action string + if id := strings.TrimSpace(getID); id != "" { + path = aiworkspace.ProviderResourcePath(orgID, id) + action = "get llm provider" + } else { + path = aiworkspace.ProviderListPath(orgID, aiworkspace.ListQuery{Limit: getLimit, Offset: getOffset}) + action = "list llm providers" + } + + resp, err := client.Get(path) + if err != nil { + return aiworkspace.WrapRequestError(action, err, getInsecure) + } + + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiws/llmprovider/root.go b/cli/src/cmd/aiws/llmprovider/root.go index 5ecb9fbcbe..9795b5b40f 100644 --- a/cli/src/cmd/aiws/llmprovider/root.go +++ b/cli/src/cmd/aiws/llmprovider/root.go @@ -42,4 +42,5 @@ var LLMProviderCmd = &cobra.Command{ func init() { LLMProviderCmd.AddCommand(pushCmd) LLMProviderCmd.AddCommand(editCmd) + LLMProviderCmd.AddCommand(getCmd) } diff --git a/cli/src/cmd/aiws/llmproxy/edit.go b/cli/src/cmd/aiws/llmproxy/edit.go index 623c8304f0..7827edacb1 100644 --- a/cli/src/cmd/aiws/llmproxy/edit.go +++ b/cli/src/cmd/aiws/llmproxy/edit.go @@ -121,7 +121,7 @@ func runEditCommand() error { } client := aiworkspace.NewClientWithOptions(aiWorkspace, editInsecure) - resp, err := client.PutJSON(aiworkspace.ProxyResourcePath(orgID, proxyID), body) + resp, err := client.PutJSON(aiworkspace.ProxyByIDPath(proxyID), body) if err != nil { return aiworkspace.WrapRequestError("update llm proxy", err, editInsecure) } diff --git a/cli/src/cmd/aiws/llmproxy/get.go b/cli/src/cmd/aiws/llmproxy/get.go new file mode 100644 index 0000000000..c7f8e02e75 --- /dev/null +++ b/cli/src/cmd/aiws/llmproxy/get.go @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmproxy + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + GetCmdLiteral = "get" + GetCmdExample = `# List all LLM proxies in a project +ap ai-ws llm-proxy get --project-id + +# Get a single LLM proxy by ID +ap ai-ws llm-proxy get --id wso2-openai-proxy + +# List using a specific AI workspace with pagination +ap ai-ws llm-proxy get --project-id --limit 50 --offset 0 --display-name my-workspace --platform eu` +) + +var ( + getID string + getProjectID string + getLimit string + getOffset string + getName string + getPlatform string + getInsecure bool +) + +var getCmd = &cobra.Command{ + Use: GetCmdLiteral, + Short: "Get one or all LLM proxies from the AI workspace", + Long: "Retrieve LLM proxies from the WSO2 API Platform AI workspace. With --id a single proxy is fetched by its identifier; without it all proxies in the project are listed (--project-id is required for listing).", + Example: GetCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runGetCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(getCmd, utils.FlagID, &getID, "", "LLM proxy ID (omit to list all)") + utils.AddStringFlag(getCmd, utils.FlagProjectID, &getProjectID, "", "Project ID (required when listing)") + utils.AddStringFlag(getCmd, utils.FlagLimit, &getLimit, "", "Maximum number of proxies to return when listing") + utils.AddStringFlag(getCmd, utils.FlagOffset, &getOffset, "", "Number of proxies to skip when listing") + utils.AddStringFlag(getCmd, utils.FlagName, &getName, "", "AI workspace display name") + utils.AddStringFlag(getCmd, utils.FlagPlatform, &getPlatform, "", "Platform name") + getCmd.Flags().BoolVar(&getInsecure, "insecure", false, "Skip TLS certificate verification") +} + +func runGetCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, getName, getPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, getInsecure) + + var path, action string + if id := strings.TrimSpace(getID); id != "" { + // Fetching a single proxy takes only the id path parameter. + path = aiworkspace.ProxyByIDPath(id) + action = "get llm proxy" + } else { + projectID := strings.TrimSpace(getProjectID) + if projectID == "" { + return fmt.Errorf("project ID is required when listing proxies (use --id to get a single proxy)") + } + path = aiworkspace.ProxyListPath(projectID, aiworkspace.ListQuery{Limit: getLimit, Offset: getOffset}) + action = "list llm proxies" + } + + resp, err := client.Get(path) + if err != nil { + return aiworkspace.WrapRequestError(action, err, getInsecure) + } + + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiws/llmproxy/root.go b/cli/src/cmd/aiws/llmproxy/root.go index 158d6eafa3..c0582e9f38 100644 --- a/cli/src/cmd/aiws/llmproxy/root.go +++ b/cli/src/cmd/aiws/llmproxy/root.go @@ -42,4 +42,5 @@ var LLMProxyCmd = &cobra.Command{ func init() { LLMProxyCmd.AddCommand(pushCmd) LLMProxyCmd.AddCommand(editCmd) + LLMProxyCmd.AddCommand(getCmd) } diff --git a/cli/src/cmd/aiws/mcpproxy/get.go b/cli/src/cmd/aiws/mcpproxy/get.go new file mode 100644 index 0000000000..62e6eef42f --- /dev/null +++ b/cli/src/cmd/aiws/mcpproxy/get.go @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package mcpproxy + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + GetCmdLiteral = "get" + GetCmdExample = `# List all MCP proxies in a project +ap ai-ws mcp-proxy get --project-id + +# Get a single MCP proxy by ID +ap ai-ws mcp-proxy get --id bijira-mcp-everything + +# List using a specific AI workspace with pagination +ap ai-ws mcp-proxy get --project-id --limit 50 --offset 0 --display-name my-workspace --platform eu` +) + +var ( + getID string + getProjectID string + getLimit string + getOffset string + getName string + getPlatform string + getInsecure bool +) + +var getCmd = &cobra.Command{ + Use: GetCmdLiteral, + Short: "Get one or all MCP proxies from the AI workspace", + Long: "Retrieve MCP proxies from the WSO2 API Platform AI workspace. With --id a single proxy is fetched by its identifier; without it all proxies in the project are listed (--project-id is required for listing).", + Example: GetCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runGetCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(getCmd, utils.FlagID, &getID, "", "MCP proxy ID (omit to list all)") + utils.AddStringFlag(getCmd, utils.FlagProjectID, &getProjectID, "", "Project ID (required when listing)") + utils.AddStringFlag(getCmd, utils.FlagLimit, &getLimit, "", "Maximum number of proxies to return when listing") + utils.AddStringFlag(getCmd, utils.FlagOffset, &getOffset, "", "Number of proxies to skip when listing") + utils.AddStringFlag(getCmd, utils.FlagName, &getName, "", "AI workspace display name") + utils.AddStringFlag(getCmd, utils.FlagPlatform, &getPlatform, "", "Platform name") + getCmd.Flags().BoolVar(&getInsecure, "insecure", false, "Skip TLS certificate verification") +} + +func runGetCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, getName, getPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, getInsecure) + + var path, action string + if id := strings.TrimSpace(getID); id != "" { + // Fetching a single proxy takes only the id path parameter. + path = aiworkspace.MCPProxyByIDPath(id) + action = "get mcp proxy" + } else { + projectID := strings.TrimSpace(getProjectID) + if projectID == "" { + return fmt.Errorf("project ID is required when listing proxies (use --id to get a single proxy)") + } + path = aiworkspace.MCPProxyListPath(projectID, aiworkspace.ListQuery{Limit: getLimit, Offset: getOffset}) + action = "list mcp proxies" + } + + resp, err := client.Get(path) + if err != nil { + return aiworkspace.WrapRequestError(action, err, getInsecure) + } + + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiws/mcpproxy/root.go b/cli/src/cmd/aiws/mcpproxy/root.go index 6babfd9813..4a022192f4 100644 --- a/cli/src/cmd/aiws/mcpproxy/root.go +++ b/cli/src/cmd/aiws/mcpproxy/root.go @@ -42,4 +42,5 @@ var MCPProxyCmd = &cobra.Command{ func init() { MCPProxyCmd.AddCommand(pushCmd) MCPProxyCmd.AddCommand(editCmd) + MCPProxyCmd.AddCommand(getCmd) } diff --git a/cli/src/internal/aiworkspace/client.go b/cli/src/internal/aiworkspace/client.go index 1f7cf0c997..69fa18376b 100644 --- a/cli/src/internal/aiworkspace/client.go +++ b/cli/src/internal/aiworkspace/client.go @@ -72,6 +72,33 @@ func (c *Client) PostJSON(path string, body []byte) (*http.Response, error) { return c.sendJSON(http.MethodPost, path, body) } +// Get sends a GET request and returns the response when it is a 2xx. +func (c *Client) Get(path string) (*http.Response, error) { + baseURL := strings.TrimSuffix(c.aiWorkspace.URL, "/") + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + req, err := http.NewRequest(http.MethodGet, baseURL+path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/json") + if baseURL != "" { + req.Header.Set("Origin", baseURL) + } + + resp, err := c.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return resp, nil + } + return nil, c.formatHTTPError(fmt.Sprintf("GET %s", path), resp) +} + func (c *Client) sendJSON(method, path string, body []byte) (*http.Response, error) { baseURL := strings.TrimSuffix(c.aiWorkspace.URL, "/") if !strings.HasPrefix(path, "/") { diff --git a/cli/src/internal/aiworkspace/helpers.go b/cli/src/internal/aiworkspace/helpers.go index a4df250da2..9c13a5a004 100644 --- a/cli/src/internal/aiworkspace/helpers.go +++ b/cli/src/internal/aiworkspace/helpers.go @@ -96,10 +96,68 @@ func MCPProxyResourcePath(orgID, id string) string { return withOrg(utils.AIWorkspaceMCPProxiesPath+"/"+url.PathEscape(id), orgID) } +// ProxyByIDPath builds the llm-proxies/{id} path. Fetching a single proxy takes +// only the id path parameter (no organizationId/projectId query). +func ProxyByIDPath(id string) string { + return utils.AIWorkspaceLLMProxiesPath + "/" + url.PathEscape(id) +} + +// MCPProxyByIDPath builds the mcp-proxies/{id} path. Fetching a single proxy +// takes only the id path parameter (no organizationId/projectId query). +func MCPProxyByIDPath(id string) string { + return utils.AIWorkspaceMCPProxiesPath + "/" + url.PathEscape(id) +} + func withOrg(path, orgID string) string { return fmt.Sprintf("%s?organizationId=%s", path, url.QueryEscape(orgID)) } +func withProject(path, projectID string) string { + return fmt.Sprintf("%s?projectId=%s", path, url.QueryEscape(projectID)) +} + +// ListQuery holds optional pagination parameters for list requests. +type ListQuery struct { + Limit string + Offset string +} + +func withListParams(basePath, orgID string, q ListQuery) string { + return appendPagination(withOrg(basePath, orgID), q) +} + +func withProjectListParams(basePath, projectID string, q ListQuery) string { + return appendPagination(withProject(basePath, projectID), q) +} + +func appendPagination(path string, q ListQuery) string { + if v := strings.TrimSpace(q.Limit); v != "" { + path += "&limit=" + url.QueryEscape(v) + } + if v := strings.TrimSpace(q.Offset); v != "" { + path += "&offset=" + url.QueryEscape(v) + } + return path +} + +// ProviderListPath builds the llm-providers list path with the organizationId +// query parameter and optional pagination. +func ProviderListPath(orgID string, q ListQuery) string { + return withListParams(utils.AIWorkspaceLLMProvidersPath, orgID, q) +} + +// ProxyListPath builds the llm-proxies list path with the projectId query +// parameter and optional pagination. +func ProxyListPath(projectID string, q ListQuery) string { + return withProjectListParams(utils.AIWorkspaceLLMProxiesPath, projectID, q) +} + +// MCPProxyListPath builds the mcp-proxies list path with the projectId query +// parameter and optional pagination. +func MCPProxyListPath(projectID string, q ListQuery) string { + return withProjectListParams(utils.AIWorkspaceMCPProxiesPath, projectID, q) +} + // ReadJSONFile reads and validates a JSON payload file from disk. func ReadJSONFile(filePath string) ([]byte, error) { resolvedPath, err := filepath.Abs(strings.TrimSpace(filePath)) diff --git a/cli/src/utils/flags.go b/cli/src/utils/flags.go index 3da8a315eb..97e17c9a1d 100644 --- a/cli/src/utils/flags.go +++ b/cli/src/utils/flags.go @@ -39,6 +39,8 @@ const ( FlagFormat = "format" FlagVersion = "version" FlagID = "id" + FlagLimit = "limit" + FlagOffset = "offset" FlagAPIID = "api-id" FlagSubID = "sub-id" FlagConfirm = "confirm" From 5e64f66ea1ef142c9de0abf5935c099bebec62df Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Fri, 19 Jun 2026 11:23:33 +0530 Subject: [PATCH 14/27] update create, update output message --- cli/src/cmd/aiws/llmprovider/delete.go | 94 +++++++++++++++++++++++++ cli/src/cmd/aiws/llmprovider/edit.go | 4 +- cli/src/cmd/aiws/llmprovider/push.go | 4 +- cli/src/cmd/aiws/llmprovider/root.go | 1 + cli/src/cmd/aiws/llmproxy/delete.go | 94 +++++++++++++++++++++++++ cli/src/cmd/aiws/llmproxy/edit.go | 4 +- cli/src/cmd/aiws/llmproxy/push.go | 4 +- cli/src/cmd/aiws/llmproxy/root.go | 1 + cli/src/cmd/aiws/mcpproxy/delete.go | 94 +++++++++++++++++++++++++ cli/src/cmd/aiws/mcpproxy/edit.go | 4 +- cli/src/cmd/aiws/mcpproxy/push.go | 4 +- cli/src/cmd/aiws/mcpproxy/root.go | 1 + cli/src/internal/aiworkspace/client.go | 13 +++- cli/src/internal/aiworkspace/helpers.go | 51 +++++++++----- docs/cli/ai-workspace/README.md | 76 +++++++++++++++++++- 15 files changed, 417 insertions(+), 32 deletions(-) create mode 100644 cli/src/cmd/aiws/llmprovider/delete.go create mode 100644 cli/src/cmd/aiws/llmproxy/delete.go create mode 100644 cli/src/cmd/aiws/mcpproxy/delete.go diff --git a/cli/src/cmd/aiws/llmprovider/delete.go b/cli/src/cmd/aiws/llmprovider/delete.go new file mode 100644 index 0000000000..0274a41b06 --- /dev/null +++ b/cli/src/cmd/aiws/llmprovider/delete.go @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmprovider + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + DeleteCmdLiteral = "delete" + DeleteCmdExample = `# Delete an LLM provider by ID using the active AI workspace +ap ai-ws llm-provider delete --id wso2-claude + +# Delete using a specific AI workspace +ap ai-ws llm-provider delete --id wso2-claude --display-name my-workspace --platform eu` +) + +var ( + deleteID string + deleteName string + deletePlatform string + deleteInsecure bool +) + +var deleteCmd = &cobra.Command{ + Use: DeleteCmdLiteral, + Short: "Delete an LLM provider from the AI workspace", + Long: "Delete an LLM provider from the WSO2 API Platform AI workspace by its identifier.", + Example: DeleteCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runDeleteCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(deleteCmd, utils.FlagID, &deleteID, "", "LLM provider ID (required)") + utils.AddStringFlag(deleteCmd, utils.FlagName, &deleteName, "", "AI workspace display name") + utils.AddStringFlag(deleteCmd, utils.FlagPlatform, &deletePlatform, "", "Platform name") + deleteCmd.Flags().BoolVar(&deleteInsecure, "insecure", false, "Skip TLS certificate verification") + _ = deleteCmd.MarkFlagRequired(utils.FlagID) +} + +func runDeleteCommand() error { + id := strings.TrimSpace(deleteID) + if id == "" { + return fmt.Errorf("LLM provider ID is required") + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, deleteName, deletePlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, deleteInsecure) + resp, err := client.Delete(aiworkspace.ProviderByIDPath(id)) + if err != nil { + return aiworkspace.WrapRequestError("delete llm provider", err, deleteInsecure) + } + resp.Body.Close() + + fmt.Printf("LLM provider %q deleted from ai-workspace %s (platform: %s)\n", id, aiWorkspace.Name, resolvedPlatform) + return nil +} diff --git a/cli/src/cmd/aiws/llmprovider/edit.go b/cli/src/cmd/aiws/llmprovider/edit.go index 44c64a18dc..67d5d81d3c 100644 --- a/cli/src/cmd/aiws/llmprovider/edit.go +++ b/cli/src/cmd/aiws/llmprovider/edit.go @@ -110,6 +110,6 @@ func runEditCommand() error { return aiworkspace.WrapRequestError("update llm provider", err, editInsecure) } - return aiworkspace.PrintArtifactResult(resp, editOutput, - fmt.Sprintf("LLM provider %q updated on ai-workspace %s (platform: %s)", providerID, aiWorkspace.Name, resolvedPlatform)) + return aiworkspace.PrintArtifactResult(resp, editOutput, providerID, + fmt.Sprintf("LLM provider updated on ai-workspace %s (platform: %s)", aiWorkspace.Name, resolvedPlatform)) } diff --git a/cli/src/cmd/aiws/llmprovider/push.go b/cli/src/cmd/aiws/llmprovider/push.go index 227e01832f..35336980bb 100644 --- a/cli/src/cmd/aiws/llmprovider/push.go +++ b/cli/src/cmd/aiws/llmprovider/push.go @@ -112,6 +112,6 @@ func runPushCommand() error { return aiworkspace.WrapRequestError("push llm provider", err, pushInsecure) } - return aiworkspace.PrintArtifactResult(resp, pushOutput, - fmt.Sprintf("LLM provider %q pushed to ai-workspace %s (platform: %s)", providerID, aiWorkspace.Name, resolvedPlatform)) + return aiworkspace.PrintArtifactResult(resp, pushOutput, providerID, + fmt.Sprintf("LLM provider pushed to ai-workspace %s (platform: %s)", aiWorkspace.Name, resolvedPlatform)) } diff --git a/cli/src/cmd/aiws/llmprovider/root.go b/cli/src/cmd/aiws/llmprovider/root.go index 9795b5b40f..6ec7ecfc35 100644 --- a/cli/src/cmd/aiws/llmprovider/root.go +++ b/cli/src/cmd/aiws/llmprovider/root.go @@ -43,4 +43,5 @@ func init() { LLMProviderCmd.AddCommand(pushCmd) LLMProviderCmd.AddCommand(editCmd) LLMProviderCmd.AddCommand(getCmd) + LLMProviderCmd.AddCommand(deleteCmd) } diff --git a/cli/src/cmd/aiws/llmproxy/delete.go b/cli/src/cmd/aiws/llmproxy/delete.go new file mode 100644 index 0000000000..893cfb7fe0 --- /dev/null +++ b/cli/src/cmd/aiws/llmproxy/delete.go @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmproxy + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + DeleteCmdLiteral = "delete" + DeleteCmdExample = `# Delete an LLM proxy by ID using the active AI workspace +ap ai-ws llm-proxy delete --id wso2-openai-proxy + +# Delete using a specific AI workspace +ap ai-ws llm-proxy delete --id wso2-openai-proxy --display-name my-workspace --platform eu` +) + +var ( + deleteID string + deleteName string + deletePlatform string + deleteInsecure bool +) + +var deleteCmd = &cobra.Command{ + Use: DeleteCmdLiteral, + Short: "Delete an LLM proxy from the AI workspace", + Long: "Delete an LLM proxy from the WSO2 API Platform AI workspace by its identifier.", + Example: DeleteCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runDeleteCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(deleteCmd, utils.FlagID, &deleteID, "", "LLM proxy ID (required)") + utils.AddStringFlag(deleteCmd, utils.FlagName, &deleteName, "", "AI workspace display name") + utils.AddStringFlag(deleteCmd, utils.FlagPlatform, &deletePlatform, "", "Platform name") + deleteCmd.Flags().BoolVar(&deleteInsecure, "insecure", false, "Skip TLS certificate verification") + _ = deleteCmd.MarkFlagRequired(utils.FlagID) +} + +func runDeleteCommand() error { + id := strings.TrimSpace(deleteID) + if id == "" { + return fmt.Errorf("LLM proxy ID is required") + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, deleteName, deletePlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, deleteInsecure) + resp, err := client.Delete(aiworkspace.ProxyByIDPath(id)) + if err != nil { + return aiworkspace.WrapRequestError("delete llm proxy", err, deleteInsecure) + } + resp.Body.Close() + + fmt.Printf("LLM proxy %q deleted from ai-workspace %s (platform: %s)\n", id, aiWorkspace.Name, resolvedPlatform) + return nil +} diff --git a/cli/src/cmd/aiws/llmproxy/edit.go b/cli/src/cmd/aiws/llmproxy/edit.go index 7827edacb1..483d56bcb5 100644 --- a/cli/src/cmd/aiws/llmproxy/edit.go +++ b/cli/src/cmd/aiws/llmproxy/edit.go @@ -126,6 +126,6 @@ func runEditCommand() error { return aiworkspace.WrapRequestError("update llm proxy", err, editInsecure) } - return aiworkspace.PrintArtifactResult(resp, editOutput, - fmt.Sprintf("LLM proxy %q updated on ai-workspace %s (platform: %s)", proxyID, aiWorkspace.Name, resolvedPlatform)) + return aiworkspace.PrintArtifactResult(resp, editOutput, proxyID, + fmt.Sprintf("LLM proxy updated on ai-workspace %s (platform: %s)", aiWorkspace.Name, resolvedPlatform)) } diff --git a/cli/src/cmd/aiws/llmproxy/push.go b/cli/src/cmd/aiws/llmproxy/push.go index 575e00f3d9..673f6b3c28 100644 --- a/cli/src/cmd/aiws/llmproxy/push.go +++ b/cli/src/cmd/aiws/llmproxy/push.go @@ -126,6 +126,6 @@ func runPushCommand() error { return aiworkspace.WrapRequestError("push llm proxy", err, pushInsecure) } - return aiworkspace.PrintArtifactResult(resp, pushOutput, - fmt.Sprintf("LLM proxy %q pushed to ai-workspace %s (platform: %s)", proxyID, aiWorkspace.Name, resolvedPlatform)) + return aiworkspace.PrintArtifactResult(resp, pushOutput, proxyID, + fmt.Sprintf("LLM proxy pushed to ai-workspace %s (platform: %s)", aiWorkspace.Name, resolvedPlatform)) } diff --git a/cli/src/cmd/aiws/llmproxy/root.go b/cli/src/cmd/aiws/llmproxy/root.go index c0582e9f38..3af3663007 100644 --- a/cli/src/cmd/aiws/llmproxy/root.go +++ b/cli/src/cmd/aiws/llmproxy/root.go @@ -43,4 +43,5 @@ func init() { LLMProxyCmd.AddCommand(pushCmd) LLMProxyCmd.AddCommand(editCmd) LLMProxyCmd.AddCommand(getCmd) + LLMProxyCmd.AddCommand(deleteCmd) } diff --git a/cli/src/cmd/aiws/mcpproxy/delete.go b/cli/src/cmd/aiws/mcpproxy/delete.go new file mode 100644 index 0000000000..1ef378674b --- /dev/null +++ b/cli/src/cmd/aiws/mcpproxy/delete.go @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package mcpproxy + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + DeleteCmdLiteral = "delete" + DeleteCmdExample = `# Delete an MCP proxy by ID using the active AI workspace +ap ai-ws mcp-proxy delete --id bijira-mcp-everything + +# Delete using a specific AI workspace +ap ai-ws mcp-proxy delete --id bijira-mcp-everything --display-name my-workspace --platform eu` +) + +var ( + deleteID string + deleteName string + deletePlatform string + deleteInsecure bool +) + +var deleteCmd = &cobra.Command{ + Use: DeleteCmdLiteral, + Short: "Delete an MCP proxy from the AI workspace", + Long: "Delete an MCP proxy from the WSO2 API Platform AI workspace by its identifier.", + Example: DeleteCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runDeleteCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(deleteCmd, utils.FlagID, &deleteID, "", "MCP proxy ID (required)") + utils.AddStringFlag(deleteCmd, utils.FlagName, &deleteName, "", "AI workspace display name") + utils.AddStringFlag(deleteCmd, utils.FlagPlatform, &deletePlatform, "", "Platform name") + deleteCmd.Flags().BoolVar(&deleteInsecure, "insecure", false, "Skip TLS certificate verification") + _ = deleteCmd.MarkFlagRequired(utils.FlagID) +} + +func runDeleteCommand() error { + id := strings.TrimSpace(deleteID) + if id == "" { + return fmt.Errorf("MCP proxy ID is required") + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, deleteName, deletePlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, deleteInsecure) + resp, err := client.Delete(aiworkspace.MCPProxyByIDPath(id)) + if err != nil { + return aiworkspace.WrapRequestError("delete mcp proxy", err, deleteInsecure) + } + resp.Body.Close() + + fmt.Printf("MCP proxy %q deleted from ai-workspace %s (platform: %s)\n", id, aiWorkspace.Name, resolvedPlatform) + return nil +} diff --git a/cli/src/cmd/aiws/mcpproxy/edit.go b/cli/src/cmd/aiws/mcpproxy/edit.go index df4b684b8c..0febab4cf5 100644 --- a/cli/src/cmd/aiws/mcpproxy/edit.go +++ b/cli/src/cmd/aiws/mcpproxy/edit.go @@ -126,6 +126,6 @@ func runEditCommand() error { return aiworkspace.WrapRequestError("update mcp proxy", err, editInsecure) } - return aiworkspace.PrintArtifactResult(resp, editOutput, - fmt.Sprintf("MCP proxy %q updated on ai-workspace %s (platform: %s)", proxyID, aiWorkspace.Name, resolvedPlatform)) + return aiworkspace.PrintArtifactResult(resp, editOutput, proxyID, + fmt.Sprintf("MCP proxy updated on ai-workspace %s (platform: %s)", aiWorkspace.Name, resolvedPlatform)) } diff --git a/cli/src/cmd/aiws/mcpproxy/push.go b/cli/src/cmd/aiws/mcpproxy/push.go index ffccbb7846..a1659bcd7b 100644 --- a/cli/src/cmd/aiws/mcpproxy/push.go +++ b/cli/src/cmd/aiws/mcpproxy/push.go @@ -126,6 +126,6 @@ func runPushCommand() error { return aiworkspace.WrapRequestError("push mcp proxy", err, pushInsecure) } - return aiworkspace.PrintArtifactResult(resp, pushOutput, - fmt.Sprintf("MCP proxy %q pushed to ai-workspace %s (platform: %s)", proxyID, aiWorkspace.Name, resolvedPlatform)) + return aiworkspace.PrintArtifactResult(resp, pushOutput, proxyID, + fmt.Sprintf("MCP proxy pushed to ai-workspace %s (platform: %s)", aiWorkspace.Name, resolvedPlatform)) } diff --git a/cli/src/cmd/aiws/mcpproxy/root.go b/cli/src/cmd/aiws/mcpproxy/root.go index 4a022192f4..e9fe1d64ba 100644 --- a/cli/src/cmd/aiws/mcpproxy/root.go +++ b/cli/src/cmd/aiws/mcpproxy/root.go @@ -43,4 +43,5 @@ func init() { MCPProxyCmd.AddCommand(pushCmd) MCPProxyCmd.AddCommand(editCmd) MCPProxyCmd.AddCommand(getCmd) + MCPProxyCmd.AddCommand(deleteCmd) } diff --git a/cli/src/internal/aiworkspace/client.go b/cli/src/internal/aiworkspace/client.go index 69fa18376b..911640aecc 100644 --- a/cli/src/internal/aiworkspace/client.go +++ b/cli/src/internal/aiworkspace/client.go @@ -74,12 +74,21 @@ func (c *Client) PostJSON(path string, body []byte) (*http.Response, error) { // Get sends a GET request and returns the response when it is a 2xx. func (c *Client) Get(path string) (*http.Response, error) { + return c.sendNoBody(http.MethodGet, path) +} + +// Delete sends a DELETE request and returns the response when it is a 2xx. +func (c *Client) Delete(path string) (*http.Response, error) { + return c.sendNoBody(http.MethodDelete, path) +} + +func (c *Client) sendNoBody(method, path string) (*http.Response, error) { baseURL := strings.TrimSuffix(c.aiWorkspace.URL, "/") if !strings.HasPrefix(path, "/") { path = "/" + path } - req, err := http.NewRequest(http.MethodGet, baseURL+path, nil) + req, err := http.NewRequest(method, baseURL+path, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -96,7 +105,7 @@ func (c *Client) Get(path string) (*http.Response, error) { if resp.StatusCode >= 200 && resp.StatusCode < 300 { return resp, nil } - return nil, c.formatHTTPError(fmt.Sprintf("GET %s", path), resp) + return nil, c.formatHTTPError(fmt.Sprintf("%s %s", method, path), resp) } func (c *Client) sendJSON(method, path string, body []byte) (*http.Response, error) { diff --git a/cli/src/internal/aiworkspace/helpers.go b/cli/src/internal/aiworkspace/helpers.go index 9c13a5a004..e629393677 100644 --- a/cli/src/internal/aiworkspace/helpers.go +++ b/cli/src/internal/aiworkspace/helpers.go @@ -96,6 +96,12 @@ func MCPProxyResourcePath(orgID, id string) string { return withOrg(utils.AIWorkspaceMCPProxiesPath+"/"+url.PathEscape(id), orgID) } +// ProviderByIDPath builds the llm-providers/{id} path with only the id path +// parameter (no organizationId/projectId query). Used for delete. +func ProviderByIDPath(id string) string { + return utils.AIWorkspaceLLMProvidersPath + "/" + url.PathEscape(id) +} + // ProxyByIDPath builds the llm-proxies/{id} path. Fetching a single proxy takes // only the id path parameter (no organizationId/projectId query). func ProxyByIDPath(id string) string { @@ -213,42 +219,55 @@ func OutputJSON(format string) bool { } // PrintArtifactResult prints the result of a create/edit operation. By default -// it prints only the concise summary line (the server-returned artifact is -// drained and discarded so it does not clutter the terminal). When the output +// it prints a concise summary line that always surfaces the artifact id (the +// value other commands need for --id), so the server-returned artifact body is +// drained and discarded rather than cluttering the terminal. When the output // format is "json" the full response body is pretty-printed instead, so the // command stays scriptable (e.g. `... -o json | jq`). -func PrintArtifactResult(resp *http.Response, outputFormat, summary string) error { +// +// fallbackID is the id known locally (from the pushed payload); it is used when +// the server response does not echo an id. +func PrintArtifactResult(resp *http.Response, outputFormat, fallbackID, summary string) error { if OutputJSON(outputFormat) { return PrintJSONResponse(resp) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) - if extra := summarizeArtifact(body); extra != "" { - summary += " " + extra + + id, details := summarizeArtifact(body) + if strings.TrimSpace(id) == "" { + id = strings.TrimSpace(fallbackID) + } + + var suffix []string + if id != "" { + suffix = append(suffix, "id: "+id) + } + suffix = append(suffix, details...) + if len(suffix) > 0 { + summary += " (" + strings.Join(suffix, ", ") + ")" } + fmt.Println(summary) return nil } -// summarizeArtifact extracts a few server-assigned fields worth surfacing in the -// summary line. It returns an empty string when none are present. -func summarizeArtifact(body []byte) string { +// summarizeArtifact extracts the server-assigned id and a few additional fields +// (version, status) worth surfacing in the summary line. +func summarizeArtifact(body []byte) (id string, details []string) { var m map[string]interface{} if err := json.Unmarshal(body, &m); err != nil { - return "" + return "", nil } - var parts []string + id, _ = m["id"].(string) if v, ok := m["version"].(string); ok && strings.TrimSpace(v) != "" { - parts = append(parts, "version: "+v) + details = append(details, "version: "+v) } if s, ok := m["status"].(string); ok && strings.TrimSpace(s) != "" { - parts = append(parts, "status: "+s) - } - if len(parts) == 0 { - return "" + details = append(details, "status: "+s) } - return "(" + strings.Join(parts, ", ") + ")" + return strings.TrimSpace(id), details } // PrintJSONResponse prints an HTTP response as pretty JSON when possible and diff --git a/docs/cli/ai-workspace/README.md b/docs/cli/ai-workspace/README.md index 735a479a9b..1caed491ae 100644 --- a/docs/cli/ai-workspace/README.md +++ b/docs/cli/ai-workspace/README.md @@ -274,6 +274,78 @@ Behavior notes: - A `.json` file target can only hold one payload, so `-o ` with **multiple** `ai-workspaces` configurations is an error — use a directory instead. - With a directory target and multiple configurations, one file per config is produced (`.json`). +## Get Commands + +These commands retrieve artifacts from the AI workspace resolved from the CLI config (`--display-name`/`--platform`, or the active AI workspace). With `--id` a single artifact is fetched; without it all artifacts are listed, with optional `--limit`/`--offset` pagination. The full JSON response is printed. + +The scoping query parameter differs by resource: + +- **LLM providers** are scoped by `organizationId` (`--org`). +- **LLM/MCP proxies** are scoped by `projectId` (`--project-id`) when listing; fetching a single proxy by `--id` takes only the id path parameter (no org/project query). + +### `ap ai-ws llm-provider get` + +```shell +# List all LLM providers +ap ai-ws llm-provider get --org [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] + +# Get a single LLM provider +ap ai-ws llm-provider get --org --id +``` + +### `ap ai-ws llm-proxy get` + +```shell +# List all LLM proxies in a project (GET /llm-proxies?projectId={project}) +ap ai-ws llm-proxy get --project-id [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] + +# Get a single LLM proxy (GET /llm-proxies/{id}) +ap ai-ws llm-proxy get --id +``` + +### `ap ai-ws mcp-proxy get` + +```shell +# List all MCP proxies in a project (GET /mcp-proxies?projectId={project}) +ap ai-ws mcp-proxy get --project-id [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] + +# Get a single MCP proxy (GET /mcp-proxies/{id}) +ap ai-ws mcp-proxy get --id +``` + +Notes: + +- For `llm-provider get`, `--org` is required. For `llm-proxy`/`mcp-proxy get`, `--project-id` is required only when listing; fetching a single proxy needs just `--id`. +- `--limit` and `--offset` apply only when listing. +- `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. + +## Delete Commands + +These commands delete an artifact by its identifier (`DELETE /{resource}/{id}`). The artifact is identified solely by `--id` — no organization or project scoping is required — and a successful delete (`204 No Content`) prints a confirmation line. + +### `ap ai-ws llm-provider delete` + +```shell +ap ai-ws llm-provider delete --id [--display-name ] [--platform ] [--insecure] +``` + +### `ap ai-ws llm-proxy delete` + +```shell +ap ai-ws llm-proxy delete --id [--display-name ] [--platform ] [--insecure] +``` + +### `ap ai-ws mcp-proxy delete` + +```shell +ap ai-ws mcp-proxy delete --id [--display-name ] [--platform ] [--insecure] +``` + +Notes: + +- `--id` is required for all delete commands. +- `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. + ## Push Commands These commands push a payload JSON (produced by `ap ai-ws build`) to the AI workspace server resolved from the CLI config (`--display-name`/`--platform`, or the active AI workspace). The resource `id` in the request URL is taken from the payload's `id` field, and credentials come from the configured auth type (see [Authentication](#authentication)). @@ -324,7 +396,7 @@ ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --org --pro Notes: - `--file` and `--org` are required for all push commands; `--project-id` is also required for LLM proxies and MCP proxies. -- By default only a concise summary line is printed. Pass `--output json` (or `-o json`) to print the full server response (useful for piping to `jq`). +- By default a concise summary line is printed, including the artifact `id` (the value other commands need for `--id`). Pass `--output json` (or `-o json`) to print the full server response instead (useful for piping to `jq`). - `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. ## Edit Commands @@ -359,7 +431,7 @@ Notes: - `--file` and `--org` are required for all edit commands; `--project-id` is also required for LLM proxies and MCP proxies. - The payload must contain the `id` of the artifact to update; it identifies the resource in the request URL. -- By default only a concise summary line is printed. Pass `--output json` (or `-o json`) to print the full server response (useful for piping to `jq`). +- By default a concise summary line is printed, including the artifact `id` (the value other commands need for `--id`). Pass `--output json` (or `-o json`) to print the full server response instead (useful for piping to `jq`). - `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. ## Related Commands From 37320dca7ab35ec0c0d81b3fdc211ac1c7979bb4 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Fri, 19 Jun 2026 21:18:15 +0530 Subject: [PATCH 15/27] add devportal gen command, update build command, fix list command issue --- cli/src/cmd/devportal/build.go | 209 +++------------------ cli/src/cmd/devportal/build_test.go | 96 ++++++---- cli/src/cmd/devportal/gen.go | 257 ++++++++++++++++++++++++++ cli/src/cmd/devportal/gen_test.go | 103 +++++++++++ cli/src/cmd/devportal/restapi/list.go | 18 +- cli/src/cmd/devportal/root.go | 1 + cli/src/utils/flags.go | 1 + 7 files changed, 462 insertions(+), 223 deletions(-) create mode 100644 cli/src/cmd/devportal/gen.go create mode 100644 cli/src/cmd/devportal/gen_test.go diff --git a/cli/src/cmd/devportal/build.go b/cli/src/cmd/devportal/build.go index bf41f0275c..17e8e026b5 100644 --- a/cli/src/cmd/devportal/build.go +++ b/cli/src/cmd/devportal/build.go @@ -97,9 +97,13 @@ func runBuildCommand() error { return err } - // create default devportal config if none exists and persist it to the project config + // Build only zips devportal folders; it never generates their contents + // (that is `ap devportal gen`'s job, which also registers the config). As a + // fallback, if the project config carries no devportal configs but the + // default ./devportal folder exists, register it here and persist it so the + // build can proceed. if len(projectConfig.DevPortals) == 0 { - portalConfig, err := createDefaultDevPortalConfig(projectConfig, projectRoot) + portalConfig, err := registerDefaultDevPortalConfig(projectRoot) if err != nil { return err } @@ -143,72 +147,6 @@ func runBuildCommand() error { return nil } -// projectAPIResource is the subset of the project metadata.yaml the devportal -// manifest is derived from. -type projectAPIResource struct { - Kind string `yaml:"kind"` - Metadata struct { - Name string `yaml:"name"` - } `yaml:"metadata"` - Spec struct { - Description string `yaml:"description"` - ReferenceID string `yaml:"referenceID"` - GatewayType string `yaml:"gatewayType"` - Tags []string `yaml:"tags"` - Labels []string `yaml:"labels"` - BusinessInformation devPortalBusinessInformation `yaml:"businessInformation"` - Endpoints devPortalEndpoints `yaml:"endpoints"` - } `yaml:"spec"` -} - -// gatewayResource is the subset of the project runtime.yaml the devportal -// manifest is derived from. -type gatewayResource struct { - Spec struct { - DisplayName string `yaml:"displayName"` - Version string `yaml:"version"` - SubscriptionPlans []string `yaml:"subscriptionPlans"` - } `yaml:"spec"` -} - -type devPortalManifest struct { - APIVersion string `yaml:"apiVersion"` - Kind string `yaml:"kind"` - Metadata devPortalManifestMeta `yaml:"metadata"` - Spec devPortalManifestSpec `yaml:"spec"` -} - -type devPortalManifestMeta struct { - Name string `yaml:"name"` -} - -type devPortalManifestSpec struct { - DisplayName string `yaml:"displayName"` - Version string `yaml:"version"` - Description string `yaml:"description"` - GatewayType string `yaml:"gatewayType"` - ReferenceID string `yaml:"referenceID"` - Tags []string `yaml:"tags"` - Labels []string `yaml:"labels"` - SubscriptionPolicies []string `yaml:"subscriptionPolicies"` - Visibility string `yaml:"visibility"` - VisibleGroups []string `yaml:"visibleGroups"` - BusinessInformation devPortalBusinessInformation `yaml:"businessInformation"` - Endpoints devPortalEndpoints `yaml:"endpoints"` -} - -type devPortalBusinessInformation struct { - BusinessOwner string `yaml:"businessOwner"` - BusinessOwnerEmail string `yaml:"businessOwnerEmail"` - TechnicalOwner string `yaml:"technicalOwner"` - TechnicalOwnerEmail string `yaml:"technicalOwnerEmail"` -} - -type devPortalEndpoints struct { - SandboxURL string `yaml:"sandboxUrl"` - ProductionURL string `yaml:"productionUrl"` -} - // failedPortal records a devportal config that could not be built so the others // can still be archived and the failures reported together. type failedPortal struct { @@ -216,8 +154,12 @@ type failedPortal struct { err error } -func createDefaultDevPortalConfig(projectConfig *project.Config, projectRoot string) (*project.PortalConfig, error) { - portalConfig := &project.PortalConfig{ +// defaultDevPortalConfig returns the portal config describing a project's +// default ./devportal folder. Both `gen` (when generating the folder) and +// `build` (as a fallback when no config is registered) use it so the layout +// stays in one place. +func defaultDevPortalConfig() project.PortalConfig { + return project.PortalConfig{ Name: "default", PortalRoot: "./devportal", FilePaths: project.PortalFilePaths{ @@ -227,124 +169,29 @@ func createDefaultDevPortalConfig(projectConfig *project.Config, projectRoot str Content: "./content", }, } - - portalRoot := resolveProjectPath(projectRoot, portalConfig.PortalRoot) - if err := os.MkdirAll(portalRoot, 0755); err != nil { - return nil, fmt.Errorf("failed to create devportal directory: %w", err) - } - - definitionSource := resolveProjectPath(projectRoot, projectConfig.FilePaths.Definition) - if err := ensureWithinProjectRoot(projectRoot, definitionSource, portalConfig.Name, "definition"); err != nil { - return nil, err - } - definitionTarget := filepath.Join(portalRoot, "definition.yaml") - if err := copyFile(definitionSource, definitionTarget); err != nil { - return nil, err - } - - docsSource := resolveProjectPath(projectRoot, projectConfig.FilePaths.Docs) - if err := ensureWithinProjectRoot(projectRoot, docsSource, portalConfig.Name, "docs"); err != nil { - return nil, err - } - docsTarget := filepath.Join(portalRoot, "docs") - if err := copyDirectory(docsSource, docsTarget); err != nil { - return nil, err - } - - contentDir := filepath.Join(portalRoot, "content") - if err := os.MkdirAll(contentDir, 0755); err != nil { - return nil, fmt.Errorf("failed to create devportal content directory: %w", err) - } - - manifest, err := buildDefaultDevPortalManifest(projectRoot, projectConfig) - if err != nil { - return nil, err - } - - manifestData, err := marshalManifest(manifest) - if err != nil { - return nil, fmt.Errorf("failed to marshal devportal manifest: %w", err) - } - - manifestPath := filepath.Join(portalRoot, "devportal.yaml") - if err := os.WriteFile(manifestPath, manifestData, 0644); err != nil { - return nil, fmt.Errorf("failed to write devportal manifest: %w", err) - } - - return portalConfig, nil } -func buildDefaultDevPortalManifest(projectRoot string, projectConfig *project.Config) (*devPortalManifest, error) { - metadataPath := resolveProjectPath(projectRoot, projectConfig.FilePaths.MetadataFile) - if err := ensureWithinProjectRoot(projectRoot, metadataPath, "default", "metadataFile"); err != nil { - return nil, err - } - runtimePath := resolveProjectPath(projectRoot, projectConfig.FilePaths.DeploymentArtifact) - if err := ensureWithinProjectRoot(projectRoot, runtimePath, "default", "deploymentArtifact"); err != nil { - return nil, err - } +// registerDefaultDevPortalConfig is build's fallback path: when the project +// config carries no devportal configs but the default ./devportal folder +// exists on disk, it returns the config describing that folder so build can +// archive it. The folder's contents are produced by `ap devportal gen`; build +// never generates them. It errors if the folder is missing. +func registerDefaultDevPortalConfig(projectRoot string) (*project.PortalConfig, error) { + portalConfig := defaultDevPortalConfig() - metadataData, err := os.ReadFile(metadataPath) - if err != nil { - return nil, fmt.Errorf("failed to read project metadata: %w", err) - } - - var apiMetadata projectAPIResource - if err := yaml.Unmarshal(metadataData, &apiMetadata); err != nil { - return nil, fmt.Errorf("failed to parse project metadata: %w", err) - } - - runtimeData, err := os.ReadFile(runtimePath) + portalRoot := resolveProjectPath(projectRoot, portalConfig.PortalRoot) + info, err := os.Stat(portalRoot) if err != nil { - return nil, fmt.Errorf("failed to read deployment artifact: %w", err) - } - - var gateway gatewayResource - if err := yaml.Unmarshal(runtimeData, &gateway); err != nil { - return nil, fmt.Errorf("failed to parse deployment artifact: %w", err) - } - - tags := apiMetadata.Spec.Tags - if len(tags) == 0 { - tags = []string{"default"} - } - - labels := apiMetadata.Spec.Labels - if len(labels) == 0 { - labels = []string{"default"} + if os.IsNotExist(err) { + return nil, fmt.Errorf("no devportal artifact found at %s; run 'ap devportal gen' to generate one", portalRoot) + } + return nil, fmt.Errorf("failed to inspect devportal directory: %w", err) } - - gatewayType := apiMetadata.Spec.GatewayType - if gatewayType == "" { - gatewayType = project.DefaultGatewayType + if !info.IsDir() { + return nil, fmt.Errorf("expected devportal artifact directory but found a file at %s", portalRoot) } - kind := strings.TrimSpace(apiMetadata.Kind) - if kind == "" { - kind = "RestApi" - } - - return &devPortalManifest{ - APIVersion: "devportal.api-platform.wso2.com/v1", - Kind: kind, - Metadata: devPortalManifestMeta{ - Name: strings.TrimSpace(apiMetadata.Metadata.Name), - }, - Spec: devPortalManifestSpec{ - DisplayName: strings.TrimSpace(gateway.Spec.DisplayName), - Version: strings.TrimSpace(gateway.Spec.Version), - Description: strings.TrimSpace(apiMetadata.Spec.Description), - GatewayType: gatewayType, - ReferenceID: strings.TrimSpace(apiMetadata.Spec.ReferenceID), - Tags: tags, - Labels: labels, - SubscriptionPolicies: gateway.Spec.SubscriptionPlans, - Visibility: "PUBLIC", - VisibleGroups: []string{}, - BusinessInformation: apiMetadata.Spec.BusinessInformation, - Endpoints: apiMetadata.Spec.Endpoints, - }, - }, nil + return &portalConfig, nil } func resolveProjectPath(projectRoot, pathValue string) string { diff --git a/cli/src/cmd/devportal/build_test.go b/cli/src/cmd/devportal/build_test.go index 0fdd954f88..d807ae9363 100644 --- a/cli/src/cmd/devportal/build_test.go +++ b/cli/src/cmd/devportal/build_test.go @@ -111,33 +111,27 @@ func TestValidateDevPortalConfig_RejectsEscapingPortalRoot(t *testing.T) { } } -func TestBuildDefaultDevPortalManifest_RejectsEscapingSourcePath(t *testing.T) { +func TestRegisterDefaultDevPortalConfig_RequiresExistingFolder(t *testing.T) { projectRoot := t.TempDir() - projectConfig := &project.Config{ - FilePaths: project.FilePaths{ - MetadataFile: "../../../etc/passwd", - DeploymentArtifact: "./runtime.yaml", - }, - } - _, err := buildDefaultDevPortalManifest(projectRoot, projectConfig) - if err == nil || !strings.Contains(err.Error(), "resolves outside the project root") { - t.Fatalf("expected containment error, got %v", err) + _, err := registerDefaultDevPortalConfig(projectRoot) + if err == nil || !strings.Contains(err.Error(), "no devportal artifact found") { + t.Fatalf("expected missing-folder error, got %v", err) } } -func TestCreateDefaultDevPortalConfig_RejectsEscapingSourcePath(t *testing.T) { +func TestRegisterDefaultDevPortalConfig_ReturnsConfigForExistingFolder(t *testing.T) { projectRoot := t.TempDir() - projectConfig := &project.Config{ - FilePaths: project.FilePaths{ - Definition: "../../outside/definition.yaml", - Docs: "./docs", - }, + if err := os.MkdirAll(filepath.Join(projectRoot, "devportal"), 0755); err != nil { + t.Fatalf("failed to create devportal dir: %v", err) } - _, err := createDefaultDevPortalConfig(projectConfig, projectRoot) - if err == nil || !strings.Contains(err.Error(), "resolves outside the project root") { - t.Fatalf("expected containment error, got %v", err) + portalConfig, err := registerDefaultDevPortalConfig(projectRoot) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if portalConfig.Name != "default" || portalConfig.PortalRoot != "./devportal" { + t.Fatalf("unexpected portal config: %+v", portalConfig) } } @@ -151,25 +145,27 @@ func TestRunBuildCommand_RequiresProjectDirectory(t *testing.T) { } } -func TestRunBuildCommand_CreatesDefaultDevPortalArtifacts(t *testing.T) { +func TestRunBuildCommand_RequiresDevPortalFolderWhenNoConfig(t *testing.T) { + projectRoot := createProjectFixture(t) + buildProjectDir = projectRoot + + err := runBuildCommand() + if err == nil || !strings.Contains(err.Error(), "no devportal artifact found") { + t.Fatalf("expected missing-folder error, got %v", err) + } +} + +func TestRunBuildCommand_RegistersExistingFolderAndArchives(t *testing.T) { projectRoot := createProjectFixture(t) + createDevPortalFolderFixture(t, projectRoot, "devportal") buildProjectDir = projectRoot if err := runBuildCommand(); err != nil { t.Fatalf("unexpected error: %v", err) } - for _, path := range []string{ - filepath.Join(projectRoot, "devportal"), - filepath.Join(projectRoot, "devportal", "devportal.yaml"), - filepath.Join(projectRoot, "devportal", "definition.yaml"), - filepath.Join(projectRoot, "devportal", "docs"), - filepath.Join(projectRoot, "devportal", "content"), - filepath.Join(projectRoot, "build", "devportal.zip"), - } { - if _, err := os.Stat(path); err != nil { - t.Fatalf("expected path to exist: %s (%v)", path, err) - } + if _, err := os.Stat(filepath.Join(projectRoot, "build", "devportal.zip")); err != nil { + t.Fatalf("expected devportal.zip to be created: %v", err) } projectConfig, err := project.Load(filepath.Join(projectRoot, ".api-platform", "config.yaml")) @@ -177,20 +173,13 @@ func TestRunBuildCommand_CreatesDefaultDevPortalArtifacts(t *testing.T) { t.Fatalf("failed to load updated project config: %v", err) } if len(projectConfig.DevPortals) != 1 || projectConfig.DevPortals[0].Name != "default" { - t.Fatalf("expected default devportal config to be added, got %+v", projectConfig.DevPortals) - } - - manifestData, err := os.ReadFile(filepath.Join(projectRoot, "devportal", "devportal.yaml")) - if err != nil { - t.Fatalf("failed to read manifest: %v", err) - } - if !strings.Contains(string(manifestData), "displayName: Petstore API") { - t.Fatalf("expected generated manifest to contain displayName, got %q", string(manifestData)) + t.Fatalf("expected default devportal config to be registered, got %+v", projectConfig.DevPortals) } } func TestRunBuildCommand_StampsReferenceIDAndGatewayType(t *testing.T) { projectRoot := createProjectFixture(t) + createDevPortalFolderFixture(t, projectRoot, "devportal") buildProjectDir = projectRoot buildReferenceID = "1ba42a09-45c0-40f8-a1bf-e4aa7cde1575" @@ -432,3 +421,30 @@ func createProjectFixture(t *testing.T) string { return projectRoot } + +// createDevPortalFolderFixture creates a complete devportal artifact folder +// (as `ap devportal gen` would) under projectRoot/portalDir so build has +// something to archive. +func createDevPortalFolderFixture(t *testing.T, projectRoot, portalDir string) { + t.Helper() + + portalRoot := filepath.Join(projectRoot, portalDir) + for _, dir := range []string{ + filepath.Join(portalRoot, "docs"), + filepath.Join(portalRoot, "content"), + } { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("failed to create devportal fixture dir %s: %v", dir, err) + } + } + + files := map[string]string{ + filepath.Join(portalRoot, "devportal.yaml"): "apiVersion: devportal.api-platform.wso2.com/v1\nkind: RestApi\nmetadata:\n name: foo-1.0.0\nspec:\n type: REST\n displayName: Petstore API\n", + filepath.Join(portalRoot, "definition.yaml"): "openapi: 3.0.0\ninfo:\n title: Petstore API\n", + } + for path, content := range files { + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write devportal fixture file %s: %v", path, err) + } + } +} diff --git a/cli/src/cmd/devportal/gen.go b/cli/src/cmd/devportal/gen.go new file mode 100644 index 0000000000..a6adbc8c2f --- /dev/null +++ b/cli/src/cmd/devportal/gen.go @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package devportal + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/project" + "github.com/wso2/api-platform/cli/utils" + "gopkg.in/yaml.v3" +) + +const ( + GenCmdLiteral = "gen" + GenCmdExample = `# Generate the default devportal artifact in the current project +ap devportal gen + +# Generate the default devportal artifact for a project in a specified directory +ap devportal gen -f /path/to/project` +) + +var genProjectDir string + +var genCmd = &cobra.Command{ + Use: GenCmdLiteral, + Short: "Generate the default devportal artifact for the project", + Long: "Generate a default devportal artifact (devportal directory with devportal.yaml, definition.yaml, docs and content) inside the project located in the specified directory (or current directory if not specified).", + Example: GenCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runGenCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(genCmd, utils.FlagFile, &genProjectDir, "", "Path to the project directory (defaults to current directory)") +} + +// genMetadataResource is the subset of the project metadata.yaml the generated +// devportal manifest is derived from. +type genMetadataResource struct { + Kind string `yaml:"kind"` + Metadata struct { + Name string `yaml:"name"` + } `yaml:"metadata"` + Spec struct { + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` + } `yaml:"spec"` +} + +func runGenCommand() error { + if genProjectDir == "" { + genProjectDir = "." + } + + projectRoot, err := filepath.Abs(genProjectDir) + if err != nil { + return fmt.Errorf("failed to resolve project directory: %w", err) + } + + projectConfigPath, err := resolveProjectConfigPath(projectRoot) + if err != nil { + return err + } + + projectConfig, err := project.Load(projectConfigPath) + if err != nil { + return err + } + + // The default devportal artifact lives in a fixed "devportal" directory at + // the project root. Refuse to overwrite an existing one so a hand-edited + // artifact is never clobbered by a regeneration. + portalRoot := filepath.Join(projectRoot, "devportal") + if _, err := os.Stat(portalRoot); err == nil { + return fmt.Errorf("devportal directory already exists: %s", portalRoot) + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to inspect devportal directory: %w", err) + } + + manifest, err := buildGeneratedDevPortalManifest(projectRoot, projectConfig) + if err != nil { + return err + } + + if err := os.MkdirAll(portalRoot, 0755); err != nil { + return fmt.Errorf("failed to create devportal directory: %w", err) + } + + // definition.yaml is copied from the project's home definition so the + // devportal artifact carries the same API definition. + definitionSource := resolveProjectPath(projectRoot, projectConfig.FilePaths.Definition) + if _, err := os.Stat(definitionSource); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("unable to find project definition file at %s", definitionSource) + } + return fmt.Errorf("failed to inspect project definition file: %w", err) + } + if err := copyFile(definitionSource, filepath.Join(portalRoot, "definition.yaml")); err != nil { + return err + } + + for _, dir := range []string{"docs", "content"} { + if err := os.MkdirAll(filepath.Join(portalRoot, dir), 0755); err != nil { + return fmt.Errorf("failed to create devportal %s directory: %w", dir, err) + } + } + + if err := os.WriteFile(filepath.Join(portalRoot, "devportal.yaml"), []byte(manifest), 0644); err != nil { + return fmt.Errorf("failed to write devportal manifest: %w", err) + } + + // Register the generated artifact in the project config so `ap devportal + // build` archives it from the config instead of having to re-detect the + // folder. Skip if a config entry already points at this folder. + portalConfig := defaultDevPortalConfig() + if !hasDevPortalConfig(projectConfig, portalConfig.PortalRoot) { + projectConfig.DevPortals = append(projectConfig.DevPortals, portalConfig) + if err := project.Save(projectConfigPath, projectConfig); err != nil { + return err + } + } + + fmt.Printf("DevPortal artifact generated at %s\n", portalRoot) + return nil +} + +// hasDevPortalConfig reports whether config already has a devportal entry whose +// portalRoot resolves to the same relative folder as portalRoot. +func hasDevPortalConfig(config *project.Config, portalRoot string) bool { + target := normalizePortalRoot(portalRoot) + for i := range config.DevPortals { + if normalizePortalRoot(config.DevPortals[i].PortalRoot) == target { + return true + } + } + return false +} + +func normalizePortalRoot(portalRoot string) string { + trimmed := strings.TrimSpace(portalRoot) + trimmed = strings.TrimPrefix(trimmed, "./") + return filepath.Clean(trimmed) +} + +// resolveProjectConfigPath validates that projectRoot is an api-platform +// project (it has a .api-platform/config.yaml) and returns the config path. +func resolveProjectConfigPath(projectRoot string) (string, error) { + projectConfigDir := filepath.Join(projectRoot, ".api-platform") + if _, err := os.Stat(projectConfigDir); os.IsNotExist(err) { + return "", fmt.Errorf("unable to find project directory, please execute this command inside a project") + } else if err != nil { + return "", fmt.Errorf("failed to inspect project directory: %w", err) + } + + projectConfigPath := filepath.Join(projectConfigDir, "config.yaml") + if _, err := os.Stat(projectConfigPath); os.IsNotExist(err) { + return "", fmt.Errorf("unable to find project directory, please execute this command inside a project") + } else if err != nil { + return "", fmt.Errorf("failed to inspect project config: %w", err) + } + + return projectConfigPath, nil +} + +// buildGeneratedDevPortalManifest renders the default devportal.yaml, sourcing +// metadata.name, spec.displayName and spec.version from the project metadata.yaml. +func buildGeneratedDevPortalManifest(projectRoot string, projectConfig *project.Config) (string, error) { + metadataPath := resolveProjectPath(projectRoot, projectConfig.FilePaths.MetadataFile) + metadataData, err := os.ReadFile(metadataPath) + if err != nil { + return "", fmt.Errorf("failed to read project metadata: %w", err) + } + + var metadata genMetadataResource + if err := yaml.Unmarshal(metadataData, &metadata); err != nil { + return "", fmt.Errorf("failed to parse project metadata: %w", err) + } + + kind := strings.TrimSpace(metadata.Kind) + if kind == "" { + kind = "RestApi" + } + + return renderGeneratedDevPortalManifest( + kind, + strings.TrimSpace(metadata.Metadata.Name), + strings.TrimSpace(metadata.Spec.DisplayName), + strings.TrimSpace(metadata.Spec.Version), + ), nil +} + +// generatedDevPortalTemplate is the default devportal artifact. The dynamic +// values (kind, metadata.name, spec.displayName, spec.version) come from the +// project metadata.yaml; the remaining fields are sample defaults the user is +// expected to edit. +const generatedDevPortalTemplate = `apiVersion: devportal.api-platform.wso2.com/v1 +kind: %s + +metadata: + name: %s + +spec: + type: REST + displayName: %s + version: %s + description: + provider: WSO2 + gatewayType: wso2/api-platform + referenceID: + + tags: [] + + labels: + - default + + subscriptionPolicies: [] + + visibility: PUBLIC + visibleGroups: [] + + businessInformation: + businessOwner: Platform Owner + businessOwnerEmail: support@example.com + technicalOwner: API Team + technicalOwnerEmail: architecture@example.com + + endpoints: + sandboxUrl: http://localhost:8080/booking + productionUrl: http://localhost:8080/booking +` + +func renderGeneratedDevPortalManifest(kind, name, displayName, version string) string { + return fmt.Sprintf(generatedDevPortalTemplate, kind, name, displayName, version) +} diff --git a/cli/src/cmd/devportal/gen_test.go b/cli/src/cmd/devportal/gen_test.go new file mode 100644 index 0000000000..626a3ed8e8 --- /dev/null +++ b/cli/src/cmd/devportal/gen_test.go @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package devportal + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunGenCommand_RequiresProjectDirectory(t *testing.T) { + genProjectDir = t.TempDir() + + err := runGenCommand() + if err == nil || err.Error() != "unable to find project directory, please execute this command inside a project" { + t.Fatalf("expected project directory error, got %v", err) + } +} + +func TestRunGenCommand_GeneratesDefaultArtifact(t *testing.T) { + projectRoot := createProjectFixture(t) + // The shared fixture's metadata.yaml omits displayName/version, which gen + // sources from metadata.yaml, so provide them here. + metadata := "apiVersion: management.api-platform.wso2.com/v1\nkind: RestApi\nmetadata:\n name: booking-api-v1.0\nspec:\n displayName: Booking API\n version: v1.0\n" + if err := os.WriteFile(filepath.Join(projectRoot, "metadata.yaml"), []byte(metadata), 0644); err != nil { + t.Fatalf("failed to write metadata fixture: %v", err) + } + genProjectDir = projectRoot + + if err := runGenCommand(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, path := range []string{ + filepath.Join(projectRoot, "devportal"), + filepath.Join(projectRoot, "devportal", "devportal.yaml"), + filepath.Join(projectRoot, "devportal", "definition.yaml"), + filepath.Join(projectRoot, "devportal", "docs"), + filepath.Join(projectRoot, "devportal", "content"), + } { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected path to exist: %s (%v)", path, err) + } + } + + manifestData, err := os.ReadFile(filepath.Join(projectRoot, "devportal", "devportal.yaml")) + if err != nil { + t.Fatalf("failed to read manifest: %v", err) + } + manifest := string(manifestData) + for _, want := range []string{ + "name: booking-api-v1.0", + "type: REST", + "displayName: Booking API", + "version: v1.0", + "provider: WSO2", + "gatewayType: wso2/api-platform", + "visibility: PUBLIC", + "- default", + } { + if !strings.Contains(manifest, want) { + t.Fatalf("expected manifest to contain %q, got:\n%s", want, manifest) + } + } + + // definition.yaml must be copied from the project home definition. + definitionData, err := os.ReadFile(filepath.Join(projectRoot, "devportal", "definition.yaml")) + if err != nil { + t.Fatalf("failed to read copied definition: %v", err) + } + if !strings.Contains(string(definitionData), "title: Petstore API") { + t.Fatalf("expected copied definition to match project home definition, got %q", string(definitionData)) + } +} + +func TestRunGenCommand_StopsWhenDevPortalExists(t *testing.T) { + projectRoot := createProjectFixture(t) + if err := os.MkdirAll(filepath.Join(projectRoot, "devportal"), 0755); err != nil { + t.Fatalf("failed to pre-create devportal dir: %v", err) + } + genProjectDir = projectRoot + + err := runGenCommand() + if err == nil || !strings.Contains(err.Error(), "devportal directory already exists") { + t.Fatalf("expected already-exists error, got %v", err) + } +} diff --git a/cli/src/cmd/devportal/restapi/list.go b/cli/src/cmd/devportal/restapi/list.go index f33bd8650f..8e9ec83789 100644 --- a/cli/src/cmd/devportal/restapi/list.go +++ b/cli/src/cmd/devportal/restapi/list.go @@ -23,6 +23,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "strings" @@ -38,16 +39,22 @@ const ( ap devportal rest-api list --org org_1 # List all APIs using a specific devportal -ap devportal rest-api list --org org_1 --display-name my-portal --platform eu` +ap devportal rest-api list --org org_1 --display-name my-portal --platform eu + +# List APIs from a specific view (defaults to "default") +ap devportal rest-api list --org org_1 --view internal` ) var ( listOrgID string listName string listPlatform string + listView string listInsecure bool ) +const defaultView = "default" + type apiListRow struct { APIID string `json:"apiID"` APIHandle string `json:"apiHandle"` @@ -74,6 +81,7 @@ func init() { utils.AddStringFlag(listCmd, utils.FlagOrgID, &listOrgID, "", "Organization ID") utils.AddStringFlag(listCmd, utils.FlagName, &listName, "", "DevPortal display name") utils.AddStringFlag(listCmd, utils.FlagPlatform, &listPlatform, "", "Platform name") + utils.AddStringFlag(listCmd, utils.FlagView, &listView, defaultView, "View to list APIs from") listCmd.Flags().BoolVar(&listInsecure, "insecure", false, "Skip TLS certificate verification") _ = listCmd.MarkFlagRequired(utils.FlagOrgID) } @@ -94,8 +102,14 @@ func runListCommand() error { return err } + view := strings.TrimSpace(listView) + if view == "" { + view = defaultView + } + client := internaldevportal.NewClientWithOptions(devPortal, listInsecure) - path := internaldevportal.OrgScopedPath(orgID, "apis?tags=default") + resource := "apis?view=" + url.QueryEscape(view) + path := internaldevportal.OrgScopedPath(orgID, resource) resp, err := client.Get(path) if err != nil { return internaldevportal.WrapRequestError("list api artifacts", err, listInsecure) diff --git a/cli/src/cmd/devportal/root.go b/cli/src/cmd/devportal/root.go index 1e5e020d00..cfaa1c7d70 100644 --- a/cli/src/cmd/devportal/root.go +++ b/cli/src/cmd/devportal/root.go @@ -53,6 +53,7 @@ func init() { DevPortalCmd.AddCommand(currentCmd) DevPortalCmd.AddCommand(healthCmd) DevPortalCmd.AddCommand(buildCmd) + DevPortalCmd.AddCommand(genCmd) DevPortalCmd.AddCommand(devportalorg.OrgCmd) DevPortalCmd.AddCommand(devportalapikey.APIKeyCmd) DevPortalCmd.AddCommand(devportalapplication.ApplicationCmd) diff --git a/cli/src/utils/flags.go b/cli/src/utils/flags.go index 97e17c9a1d..0b52fda82e 100644 --- a/cli/src/utils/flags.go +++ b/cli/src/utils/flags.go @@ -52,6 +52,7 @@ const ( FlagHeader = "header" FlagPolicyId = "policy-id" FlagOrgID = "org" + FlagView = "view" FlagRequestCount = "request-count" FlagEventCount = "event-count" FlagPricingModel = "pricing-model" From f73339cd696824c3d57f8ca4d0532a54854fea60 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Mon, 22 Jun 2026 11:40:28 +0530 Subject: [PATCH 16/27] standardize file names of devportal build artifact --- cli/src/cmd/devportal/build.go | 40 +++++++++++++--------------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/cli/src/cmd/devportal/build.go b/cli/src/cmd/devportal/build.go index 17e8e026b5..1b56dc31f2 100644 --- a/cli/src/cmd/devportal/build.go +++ b/cli/src/cmd/devportal/build.go @@ -31,6 +31,17 @@ import ( "gopkg.in/yaml.v3" ) +// Standard names every devportal artifact takes inside the archive, regardless +// of the source paths configured in .api-platform/config.yaml. Standardizing +// here gives the published zip a predictable layout for the devportal to +// unpack, while letting authors keep arbitrary on-disk filenames. +const ( + archiveMetadataFileName = "devportal.yaml" + archiveDefinitionFileName = "definition.yaml" + archiveDocsDirName = "docs" + archiveContentDirName = "content" +) + const ( BuildCmdLiteral = "build" BuildCmdExample = `# Build the project in the current directory for devportal @@ -461,22 +472,22 @@ func createDevPortalArchiveStagingDir(projectRoot, buildDir string, portalConfig }{ { source: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.MetadataFile), - target: filepath.Join(stagingRoot, archiveRelativePath(portalConfig.FilePaths.MetadataFile)), + target: filepath.Join(stagingRoot, archiveMetadataFileName), isDir: false, }, { source: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.Definition), - target: filepath.Join(stagingRoot, archiveRelativePath(portalConfig.FilePaths.Definition)), + target: filepath.Join(stagingRoot, archiveDefinitionFileName), isDir: false, }, { source: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.Docs), - target: filepath.Join(stagingRoot, archiveRelativePath(portalConfig.FilePaths.Docs)), + target: filepath.Join(stagingRoot, archiveDocsDirName), isDir: true, }, { source: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.Content), - target: filepath.Join(stagingRoot, archiveRelativePath(portalConfig.FilePaths.Content)), + target: filepath.Join(stagingRoot, archiveContentDirName), isDir: true, }, } @@ -508,27 +519,6 @@ func resolvePortalConfigPath(projectRoot string, portalConfig *project.PortalCon return filepath.Clean(filepath.Join(portalRoot, trimmed)) } -func archiveRelativePath(pathValue string) string { - cleanPath := filepath.Clean(strings.TrimSpace(pathValue)) - cleanPath = strings.TrimPrefix(cleanPath, "."+string(os.PathSeparator)) - cleanPath = strings.TrimPrefix(cleanPath, "."+"/") - - for strings.HasPrefix(cleanPath, ".."+string(os.PathSeparator)) || cleanPath == ".." { - cleanPath = strings.TrimPrefix(cleanPath, ".."+string(os.PathSeparator)) - if cleanPath == ".." { - cleanPath = "" - } - } - - cleanPath = strings.TrimPrefix(cleanPath, string(os.PathSeparator)) - cleanPath = strings.TrimPrefix(cleanPath, "/") - if cleanPath == "" || cleanPath == "." { - return "content" - } - - return cleanPath -} - // ensureUniqueDevPortalZipNames fails fast when two devportal configs sanitize // to the same archive filename, which would otherwise silently overwrite an // earlier artifact during the build loop. From afd852cbf3efe86c2273c789f2cea1aaa62d309b Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Mon, 22 Jun 2026 16:10:12 +0530 Subject: [PATCH 17/27] add cli end to end flow --- docs/cli/README.md | 1 + docs/cli/end-to-end-workflow.md | 105 ++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 docs/cli/end-to-end-workflow.md diff --git a/docs/cli/README.md b/docs/cli/README.md index 0c121aff0d..54961626e7 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -7,6 +7,7 @@ | Document | Description | |----------|-------------| | [Quick Start Guide](quick-start-guide.md) | Install the `ap` binary and configure environment variables to get up and running | +| [End-to-End Workflow](end-to-end-workflow.md) | The full flow with a diagram: create a project → deploy to the gateway → build and publish to the Developer Portal or an AI Workspace | | [CLI Reference](reference.md) | Full reference for all `ap gateway` sub-commands (add, list, apply, build, MCP, and more) | | [Customizing Gateway Policies](customizing-gateway-policies.md) | Build custom gateway images with local or PolicyHub policies using `ap gateway image build` | | [AI-Workspace CLI Reference](ai-workspace/README.md) | Manage AI-Workspace connections (`add`, `list`, `use`, `current`, `remove`) and generate LLM proxy creation payloads with `ap ai-ws build` | diff --git a/docs/cli/end-to-end-workflow.md b/docs/cli/end-to-end-workflow.md new file mode 100644 index 0000000000..7182f2da03 --- /dev/null +++ b/docs/cli/end-to-end-workflow.md @@ -0,0 +1,105 @@ +# End-to-End Workflow + +This guide shows the full lifecycle with the `ap` CLI: create an API **project**, **deploy** it to a gateway, then **build** a portal artifact and **publish** it — either to the **Developer Portal** or to an **AI Workspace**. + +A single API project is the source of truth for all destinations: + +- `runtime.yaml` → deployed to the **gateway** (where the API is served). +- `metadata.yaml` + `definition.yaml` → used to generate the default Developer Portal artifact. +- `metadata.yaml` + `runtime.yaml` + `definition.yaml` → bundled into the **AI Workspace** artifact. + +## Flow + +```mermaid +flowchart LR + A["0 · Set up once
connect + select
gateway · devportal · ai-ws"] --> B["1 · Create
ap project init"] + B --> C["Author
metadata.yaml · runtime.yaml · definition.yaml"] + C --> D["2 · Deploy
ap gateway apply -f runtime.yaml"] + D --> E{"Publish to?"} + + subgraph DP["Developer Portal"] + direction LR + F["3a · Generate
ap devportal gen"] --> G["4a · Build
ap devportal build"] --> H["5a · Publish
ap devportal rest-api publish"] + end + + subgraph AW["AI Workspace"] + direction LR + I["3b · Build
ap ai-ws build"] --> J["4b · Push
ap ai-ws llm-proxy push"] + end + + E -->|REST API| F + E -->|LLM proxy/ provider / MCP proxy| I + + classDef dp fill:#e8f0fe,stroke:#4285f4,color:#1a3d7c; + classDef aiws fill:#e6f4ea,stroke:#34a853,color:#1e4620; + class F,G,H dp; + class I,J aiws; +``` + +## Steps + +### 0. Configure connections (one-time) + +Register and select the servers the CLI talks to. Each connection lives under the active platform. + +```shell +ap platform add --display-name --control-plane # optional; if you use platforms +ap gateway add --display-name --server && ap gateway use --display-name +ap devportal add --display-name --server --auth api-key && ap devportal use --display-name +ap ai-ws add --display-name --server --auth api-key && ap ai-ws use --display-name +``` + +Commands resolve the **active** gateway / devportal / ai-workspace of the active platform unless you pass `--display-name` (and `--platform`). See [Gateway](gateway/README.md), [DevPortal](devportal/README.md), and [AI-Workspace](ai-workspace/README.md) references. + +### 1. Create the project + +```shell +ap project init --display-name echo-api --type rest --version v2.0 --context /ping +cd echo-api +``` + +Scaffolds `metadata.yaml`, `runtime.yaml`, `definition.yaml`, `docs/`, `tests/`, and `.api-platform/config.yaml`. See the [API Project reference](apiproject/README.md). + +### 2. Deploy to the gateway + +Edit `runtime.yaml` (real upstream, policies, operations), then deploy: + +```shell +ap gateway apply -f runtime.yaml +``` + +The response includes the gateway-assigned **API ID**. Re-read it any time with +`ap gateway rest-api get --display-name "" --version `. + +### 3+. Build the portal artifact and publish + +Pick the destination for the deployed API. + +#### Developer Portal + +```shell +# Set spec.referenceID in metadata.yaml to the gateway API ID from step 2, then: +ap devportal gen # generate ./devportal (devportal.yaml, definition, docs, content) +ap devportal build # package ./devportal → build/devportal.zip +ap devportal rest-api publish -f build/devportal.zip --org +``` + +`gen` generates the devportal artifact source and registers it in the project config; edit `./devportal/devportal.yaml` to customize before `build`. `build` only packages the generated folder (run `gen` first). Follow-ups once published: `ap devportal sub-plan publish`, `ap devportal api-key generate`, `ap devportal subscription create`. + +#### AI Workspace + +```shell +ap ai-ws build # → build/.json +ap ai-ws llm-proxy push -f build/.json --org +# the same pattern applies to: ap ai-ws llm-provider push / ap ai-ws mcp-proxy push +``` + +`ap ai-ws build` reads each ai-workspace entry in `.api-platform/config.yaml` and generates an LLM proxy creation payload (JSON). Pass `--use-spec` to fold the OpenAPI spec from `definition.yaml` into the payload. + +## Notes + +- `ap devportal gen`, `ap devportal build`, and `ap ai-ws build` all operate on an API project (they require `.api-platform/config.yaml`). +- Developer Portal is two stages: `gen` **generates** the editable artifact source under `./devportal`, then `build` **packages** it into `build/devportal.zip`. AI Workspace's `build` generates the JSON payload directly (no separate `gen`). +- Both `build` commands write into the project's `build/` directory, one artifact per configured portal entry. +- `--org` on the publish/push commands is the target organization in the Developer Portal / AI Workspace. +- Add `--insecure` to any portal/gateway command when talking to a local or self-signed HTTPS endpoint. From 22f56c2f47d7b328feacac496e4a0fb5f0a981b1 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Tue, 23 Jun 2026 16:29:16 +0530 Subject: [PATCH 18/27] add list cli commands for llm provider & subplan --- cli/src/cmd/aiws/llmprovider/list.go | 101 ++++++++++++++++++ cli/src/cmd/aiws/llmprovider/root.go | 1 + .../cmd/devportal/subplan/commands_test.go | 36 +++++++ cli/src/cmd/devportal/subplan/list.go | 98 +++++++++++++++++ cli/src/cmd/devportal/subplan/root.go | 1 + docs/cli/ai-workspace/README.md | 9 ++ docs/cli/devportal/README.md | 19 ++++ 7 files changed, 265 insertions(+) create mode 100644 cli/src/cmd/aiws/llmprovider/list.go create mode 100644 cli/src/cmd/devportal/subplan/list.go diff --git a/cli/src/cmd/aiws/llmprovider/list.go b/cli/src/cmd/aiws/llmprovider/list.go new file mode 100644 index 0000000000..3175328955 --- /dev/null +++ b/cli/src/cmd/aiws/llmprovider/list.go @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmprovider + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + ListCmdLiteral = "list" + ListCmdExample = `# List all LLM providers in an organization +ap ai-ws llm-provider list --org + +# List with pagination +ap ai-ws llm-provider list --org --limit 50 --offset 0 + +# List using a specific AI workspace +ap ai-ws llm-provider list --org --display-name my-workspace --platform eu` +) + +var ( + listOrgID string + listLimit string + listOffset string + listName string + listPlatform string + listInsecure bool +) + +var listCmd = &cobra.Command{ + Use: ListCmdLiteral, + Short: "List all LLM providers in the AI workspace", + Long: "Retrieves all LLM providers for a given organization from the WSO2 API Platform AI workspace.", + Example: ListCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runListCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(listCmd, utils.FlagOrgID, &listOrgID, "", "Organization ID (required)") + utils.AddStringFlag(listCmd, utils.FlagLimit, &listLimit, "", "Maximum number of providers to return") + utils.AddStringFlag(listCmd, utils.FlagOffset, &listOffset, "", "Number of providers to skip") + utils.AddStringFlag(listCmd, utils.FlagName, &listName, "", "AI workspace display name") + utils.AddStringFlag(listCmd, utils.FlagPlatform, &listPlatform, "", "Platform name") + listCmd.Flags().BoolVar(&listInsecure, "insecure", false, "Skip TLS certificate verification") + _ = listCmd.MarkFlagRequired(utils.FlagOrgID) +} + +func runListCommand() error { + orgID := strings.TrimSpace(listOrgID) + if orgID == "" { + return fmt.Errorf("organization ID is required") + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, listName, listPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, listInsecure) + path := aiworkspace.ProviderListPath(orgID, aiworkspace.ListQuery{Limit: listLimit, Offset: listOffset}) + + resp, err := client.Get(path) + if err != nil { + return aiworkspace.WrapRequestError("list llm providers", err, listInsecure) + } + + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiws/llmprovider/root.go b/cli/src/cmd/aiws/llmprovider/root.go index 6ec7ecfc35..5f678c75d2 100644 --- a/cli/src/cmd/aiws/llmprovider/root.go +++ b/cli/src/cmd/aiws/llmprovider/root.go @@ -42,6 +42,7 @@ var LLMProviderCmd = &cobra.Command{ func init() { LLMProviderCmd.AddCommand(pushCmd) LLMProviderCmd.AddCommand(editCmd) + LLMProviderCmd.AddCommand(listCmd) LLMProviderCmd.AddCommand(getCmd) LLMProviderCmd.AddCommand(deleteCmd) } diff --git a/cli/src/cmd/devportal/subplan/commands_test.go b/cli/src/cmd/devportal/subplan/commands_test.go index a9193cba4f..3ec35ac923 100644 --- a/cli/src/cmd/devportal/subplan/commands_test.go +++ b/cli/src/cmd/devportal/subplan/commands_test.go @@ -208,6 +208,42 @@ func TestRunGetCommand_MissingPolicyID(t *testing.T) { } } +func TestRunListCommand_ListsPlans(t *testing.T) { + testutil.WithTempHome(t) + + server := testutil.NewDevPortalServer(t, func(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + t.Fatalf("expected GET request, got %s", req.Method) + } + if req.URL.Path != "/o/org-1/devportal/v1/subscription-policies" { + t.Fatalf("unexpected request path %s", req.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"policyId":"plan-1","policyName":"Gold"}]`)) + }) + + writeSubPlanConfig(t, server.URL) + + listOrgID = "org-1" + listName = "" + listPlatform = "" + listInsecure = false + + if err := runListCommand(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunListCommand_MissingOrgID(t *testing.T) { + testutil.WithTempHome(t) + + listOrgID = "" + + if err := runListCommand(); err == nil || !strings.Contains(err.Error(), "organization ID is required") { + t.Fatalf("expected organization ID validation error, got %v", err) + } +} + func TestRunDeleteCommand_DeletesPlanByPolicyID(t *testing.T) { testutil.WithTempHome(t) diff --git a/cli/src/cmd/devportal/subplan/list.go b/cli/src/cmd/devportal/subplan/list.go new file mode 100644 index 0000000000..b50333c0c4 --- /dev/null +++ b/cli/src/cmd/devportal/subplan/list.go @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package subplan + +import ( + "fmt" + "net/http" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + internaldevportal "github.com/wso2/api-platform/cli/internal/devportal" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + ListCmdLiteral = "list" + ListCmdExample = `# List all subscription plans in an organization using the active devportal +ap devportal sub-plan list --org org_1 + +# List all subscription plans using a specific devportal +ap devportal sub-plan list --org org_1 --display-name my-portal --platform eu` +) + +var ( + listOrgID string + listName string + listPlatform string + listInsecure bool +) + +var listCmd = &cobra.Command{ + Use: ListCmdLiteral, + Short: "List all DevPortal subscription plans", + Long: "Retrieves all subscription plans for a given DevPortal organization.", + Example: ListCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runListCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(listCmd, utils.FlagOrgID, &listOrgID, "", "Organization ID") + utils.AddStringFlag(listCmd, utils.FlagName, &listName, "", "DevPortal display name") + utils.AddStringFlag(listCmd, utils.FlagPlatform, &listPlatform, "", "Platform name") + utils.AddBoolFlag(listCmd, utils.FlagInsecure, &listInsecure, false, "Skip TLS certificate verification") + _ = listCmd.MarkFlagRequired(utils.FlagOrgID) +} + +func runListCommand() error { + orgID := strings.TrimSpace(listOrgID) + if orgID == "" { + return fmt.Errorf("organization ID is required") + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + devPortal, resolvedPlatform, err := internaldevportal.ResolveDevPortal(cfg, listName, listPlatform) + if err != nil { + return err + } + + client := internaldevportal.NewClientWithOptions(devPortal, listInsecure) + path := internaldevportal.OrgScopedPath(orgID, "subscription-policies") + resp, err := client.Get(path) + if err != nil { + return internaldevportal.WrapRequestError("list subscription plans", err, listInsecure) + } + if resp.StatusCode != http.StatusOK { + return utils.FormatHTTPError("list subscription plans", resp, "DevPortal") + } + + fmt.Printf("Subscription plans from devportal %s (platform: %s)\n", devPortal.Name, resolvedPlatform) + return internaldevportal.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/devportal/subplan/root.go b/cli/src/cmd/devportal/subplan/root.go index 729de03687..f24deb9018 100644 --- a/cli/src/cmd/devportal/subplan/root.go +++ b/cli/src/cmd/devportal/subplan/root.go @@ -44,6 +44,7 @@ var SubPlanCmd = &cobra.Command{ func init() { SubPlanCmd.AddCommand(publishCmd) + SubPlanCmd.AddCommand(listCmd) SubPlanCmd.AddCommand(getCmd) SubPlanCmd.AddCommand(deleteCmd) } diff --git a/docs/cli/ai-workspace/README.md b/docs/cli/ai-workspace/README.md index 1caed491ae..593da8bc6f 100644 --- a/docs/cli/ai-workspace/README.md +++ b/docs/cli/ai-workspace/README.md @@ -283,6 +283,14 @@ The scoping query parameter differs by resource: - **LLM providers** are scoped by `organizationId` (`--org`). - **LLM/MCP proxies** are scoped by `projectId` (`--project-id`) when listing; fetching a single proxy by `--id` takes only the id path parameter (no org/project query). +### `ap ai-ws llm-provider list` + +Lists all LLM providers in an organization (`GET /llm-providers?organizationId={org}`, operationId `listLLMProviders`). + +```shell +ap ai-ws llm-provider list --org [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] +``` + ### `ap ai-ws llm-provider get` ```shell @@ -315,6 +323,7 @@ ap ai-ws mcp-proxy get --id Notes: +- `llm-provider list` and `llm-provider get` both list providers when no `--id` is given; `list` is the dedicated list-all command (`--org` required), while `get` additionally fetches a single provider with `--id`. - For `llm-provider get`, `--org` is required. For `llm-proxy`/`mcp-proxy get`, `--project-id` is required only when listing; fetching a single proxy needs just `--id`. - `--limit` and `--offset` apply only when listing. - `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. diff --git a/docs/cli/devportal/README.md b/docs/cli/devportal/README.md index 9c5c81de53..36bbda4d53 100644 --- a/docs/cli/devportal/README.md +++ b/docs/cli/devportal/README.md @@ -727,6 +727,25 @@ Notes: - `type` accepts `requestcount` or `eventcount`. Use `-1` for an unlimited request/event count. - Equivalent request shape: `curl -X POST /devportal/organizations//subscription-policies -F "subscriptionPolicy=@sub_plan_gold.yaml"`. +### `ap devportal sub-plan list` + +Lists all subscription plans in an organization. + +```shell +ap devportal sub-plan list --org [--display-name ] [--platform ] [--insecure] +``` + +Examples: + +```shell +ap devportal sub-plan list --org org_1 +ap devportal sub-plan list --org org_1 --display-name my-portal --platform eu +``` + +Notes: + +- Calls `GET /o//devportal/v1/subscription-policies` (operationId `listSubscriptionPolicies`). + ### `ap devportal sub-plan get` Gets a single subscription plan by its policy ID. From 02807f6c88ce69fed64093fc8fe1cad22e5655ea Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Thu, 2 Jul 2026 13:32:36 +0530 Subject: [PATCH 19/27] update ai-ws cli commands with newly intrduced associatedGateways section --- cli/src/cmd/aiws/build.go | 120 +++++++++++++++++--------- cli/src/cmd/gateway/apply.go | 2 +- cli/src/internal/gateway/resources.go | 40 ++++++++- cli/src/utils/constants.go | 22 +++-- docs/cli/ai-workspace/README.md | 23 +++++ 5 files changed, 153 insertions(+), 54 deletions(-) diff --git a/cli/src/cmd/aiws/build.go b/cli/src/cmd/aiws/build.go index 19f0c0c59a..e35fca83c6 100644 --- a/cli/src/cmd/aiws/build.go +++ b/cli/src/cmd/aiws/build.go @@ -378,12 +378,13 @@ func loadAIWorkspaceSpec(projectRoot, baseDir string, config *project.AIWorkspac // command's pending design questions) and are omitted from the payload. func buildLLMProviderPayload(name string, metadata aiWorkspaceMetadata, runtime aiWorkspaceRuntime, openapi string) llmProviderPayload { payload := llmProviderPayload{ - ID: name, - Name: name, - Version: strings.TrimSpace(metadata.Spec.Version), - Context: strings.TrimSpace(runtime.Spec.Context), - Template: strings.TrimSpace(runtime.Spec.Template), - OpenAPI: openapi, + ID: name, + Name: name, + Version: strings.TrimSpace(metadata.Spec.Version), + Context: strings.TrimSpace(runtime.Spec.Context), + Template: strings.TrimSpace(runtime.Spec.Template), + OpenAPI: openapi, + AssociatedGateways: normalizeAssociatedGateways(metadata.AssociatedGateways), } if up := runtime.Spec.Upstream; up != nil { @@ -451,6 +452,7 @@ func buildMCPProxyPayload(name string, metadata aiWorkspaceMetadata, runtime aiW Resources: mcpResources(definition.Resources), Tools: definition.Tools, }, + AssociatedGateways: normalizeAssociatedGateways(metadata.AssociatedGateways), } if up := runtime.Spec.Upstream; up != nil { @@ -489,6 +491,25 @@ func mcpResources(resources []map[string]interface{}) []map[string]interface{} { return out } +// normalizeAssociatedGateways trims the associatedGateways read from +// metadata.yaml: gateway ids are trimmed and entries without an id are +// dropped. It returns nil when nothing remains so the payload omits the field +// (matching the optional schema in openapi.yaml). +func normalizeAssociatedGateways(gateways []associatedGateway) []associatedGateway { + out := make([]associatedGateway, 0, len(gateways)) + for _, gateway := range gateways { + id := strings.TrimSpace(gateway.ID) + if id == "" { + continue + } + out = append(out, associatedGateway{ID: id, Configurations: gateway.Configurations}) + } + if len(out) == 0 { + return nil + } + return out +} + // buildRateLimitingFromPolicies maps the *-ratelimit policies in runtime.yaml // into the provider's rateLimiting block. The policy name selects the dimension // (advanced-* -> request, token-based-* -> token, llm-cost-based-* -> cost) and @@ -760,14 +781,15 @@ func buildSecurityFromPolicies(policies []runtimeProviderPolicy) *securityConfig // caller to fill in at publish time. func buildLLMProxyPayload(proxyName string, metadata aiWorkspaceMetadata, runtime aiWorkspaceRuntime, openapi string) llmProxyPayload { payload := llmProxyPayload{ - ID: proxyName, - Name: proxyName, - Version: strings.TrimSpace(metadata.Spec.Version), - Context: strings.TrimSpace(runtime.Spec.Context), - Vhost: "", - OpenAPI: openapi, - Provider: llmProxyProvider{ID: strings.TrimSpace(runtime.Spec.Provider.ID)}, - Policies: []llmPolicy{}, + ID: proxyName, + Name: proxyName, + Version: strings.TrimSpace(metadata.Spec.Version), + Context: strings.TrimSpace(runtime.Spec.Context), + Vhost: "", + OpenAPI: openapi, + Provider: llmProxyProvider{ID: strings.TrimSpace(runtime.Spec.Provider.ID)}, + Policies: []llmPolicy{}, + AssociatedGateways: normalizeAssociatedGateways(metadata.AssociatedGateways), } if auth := runtime.Spec.Provider.Auth; auth != nil { @@ -818,6 +840,17 @@ type aiWorkspaceMetadata struct { DisplayName string `yaml:"displayName"` Version string `yaml:"version"` } `yaml:"spec"` + // AssociatedGateways is a top-level section in metadata.yaml (a sibling of + // spec), not nested under spec. + AssociatedGateways []associatedGateway `yaml:"associatedGateways"` +} + +// associatedGateway mirrors the AssociatedGateway schema (openapi.yaml): the +// gateway id plus a free-form per-gateway configuration override. The same +// shape is used to parse metadata.yaml and to emit the build payload. +type associatedGateway struct { + ID string `json:"id" yaml:"id"` + Configurations map[string]interface{} `json:"configurations,omitempty" yaml:"configurations"` } type aiWorkspaceRuntime struct { @@ -882,14 +915,15 @@ type runtimePolicyPath struct { // --- createLLMProxy request body (subset; see openapi.yaml LLMProxy schema) --- type llmProxyPayload struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Context string `json:"context,omitempty"` - Vhost string `json:"vhost"` - Provider llmProxyProvider `json:"provider"` - OpenAPI string `json:"openapi"` - Policies []llmPolicy `json:"policies"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Context string `json:"context,omitempty"` + Vhost string `json:"vhost"` + Provider llmProxyProvider `json:"provider"` + OpenAPI string `json:"openapi"` + Policies []llmPolicy `json:"policies"` + AssociatedGateways []associatedGateway `json:"associatedGateways,omitempty"` } type llmProxyProvider struct { @@ -926,15 +960,16 @@ type mcpDefinition struct { } type mcpProxyPayload struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Context string `json:"context,omitempty"` - Description string `json:"description"` - MCPSpecVersion string `json:"mcpSpecVersion,omitempty"` - Upstream *llmUpstream `json:"upstream,omitempty"` - Capabilities *mcpCapabilities `json:"capabilities,omitempty"` - Policies []mcpPolicy `json:"policies,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Context string `json:"context,omitempty"` + Description string `json:"description"` + MCPSpecVersion string `json:"mcpSpecVersion,omitempty"` + Upstream *llmUpstream `json:"upstream,omitempty"` + Capabilities *mcpCapabilities `json:"capabilities,omitempty"` + Policies []mcpPolicy `json:"policies,omitempty"` + AssociatedGateways []associatedGateway `json:"associatedGateways,omitempty"` } type mcpCapabilities struct { @@ -952,17 +987,18 @@ type mcpPolicy struct { // --- createLLMProvider request body (subset; see openapi.yaml LLMProvider schema) --- type llmProviderPayload struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Context string `json:"context,omitempty"` - Template string `json:"template"` - Upstream *llmUpstream `json:"upstream,omitempty"` - AccessControl *llmAccessControl `json:"accessControl,omitempty"` - OpenAPI string `json:"openapi"` - RateLimiting *llmRateLimiting `json:"rateLimiting,omitempty"` - Security *securityConfig `json:"security,omitempty"` - Policies []llmPolicy `json:"policies,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Context string `json:"context,omitempty"` + Template string `json:"template"` + Upstream *llmUpstream `json:"upstream,omitempty"` + AccessControl *llmAccessControl `json:"accessControl,omitempty"` + OpenAPI string `json:"openapi"` + RateLimiting *llmRateLimiting `json:"rateLimiting,omitempty"` + Security *securityConfig `json:"security,omitempty"` + Policies []llmPolicy `json:"policies,omitempty"` + AssociatedGateways []associatedGateway `json:"associatedGateways,omitempty"` } type llmRateLimiting struct { diff --git a/cli/src/cmd/gateway/apply.go b/cli/src/cmd/gateway/apply.go index 49041949fe..94d248172a 100644 --- a/cli/src/cmd/gateway/apply.go +++ b/cli/src/cmd/gateway/apply.go @@ -49,7 +49,7 @@ var ( var applyCmd = &cobra.Command{ Use: ApplyCmdLiteral, Short: "Apply a resource to the gateway", - Long: "Create or update a gateway resource (API, MCP proxy, etc.) from a YAML or JSON file.", + Long: "Create or update a gateway resource (RestApi, Mcp, LlmProvider, LlmProxy) from a YAML or JSON file.", Example: ApplyCmdExample, Run: func(cmd *cobra.Command, args []string) { if err := runApplyCommand(cmd); err != nil { diff --git a/cli/src/internal/gateway/resources.go b/cli/src/internal/gateway/resources.go index d161a2aa49..95f94ca4ac 100644 --- a/cli/src/internal/gateway/resources.go +++ b/cli/src/internal/gateway/resources.go @@ -26,8 +26,10 @@ import ( // ResourceKind represents the type of gateway resource const ( - ResourceKindRestAPI = "RestApi" - ResourceKindMCP = "Mcp" + ResourceKindRestAPI = "RestApi" + ResourceKindMCP = "Mcp" + ResourceKindLLMProvider = "LlmProvider" + ResourceKindLLMProxy = "LlmProxy" ) // Resource represents a parsed gateway resource @@ -79,6 +81,36 @@ func (h *MCPHandler) UpdateEndpoint(handle string) string { return fmt.Sprintf(utils.GatewayMCPProxyByIDPath, handle) } +// LLMProviderHandler handles LlmProvider kind resources +type LLMProviderHandler struct{} + +func (h *LLMProviderHandler) GetEndpoint(handle string) string { + return fmt.Sprintf(utils.GatewayLLMProviderByIDPath, handle) +} + +func (h *LLMProviderHandler) CreateEndpoint() string { + return utils.GatewayLLMProvidersPath +} + +func (h *LLMProviderHandler) UpdateEndpoint(handle string) string { + return fmt.Sprintf(utils.GatewayLLMProviderByIDPath, handle) +} + +// LLMProxyHandler handles LlmProxy kind resources +type LLMProxyHandler struct{} + +func (h *LLMProxyHandler) GetEndpoint(handle string) string { + return fmt.Sprintf(utils.GatewayLLMProxyByIDPath, handle) +} + +func (h *LLMProxyHandler) CreateEndpoint() string { + return utils.GatewayLLMProxiesPath +} + +func (h *LLMProxyHandler) UpdateEndpoint(handle string) string { + return fmt.Sprintf(utils.GatewayLLMProxyByIDPath, handle) +} + // GetResourceHandler returns the appropriate handler for a resource kind func GetResourceHandler(kind string) ResourceHandler { switch kind { @@ -86,6 +118,10 @@ func GetResourceHandler(kind string) ResourceHandler { return &RestAPIHandler{} case ResourceKindMCP: return &MCPHandler{} + case ResourceKindLLMProvider: + return &LLMProviderHandler{} + case ResourceKindLLMProxy: + return &LLMProxyHandler{} default: return nil } diff --git a/cli/src/utils/constants.go b/cli/src/utils/constants.go index 93bcdd5783..a097314037 100644 --- a/cli/src/utils/constants.go +++ b/cli/src/utils/constants.go @@ -37,12 +37,16 @@ const ( DefaultGatewayRuntime = "ghcr.io/wso2/api-platform/gateway-runtime:%s" // %s = version // REST API Endpoints - GatewayHealthPath = "/health" - GatewayAPIsPath = "/rest-apis" - GatewayAPIByIDPath = "/rest-apis/%s" - GatewayMCPProxiesPath = "/mcp-proxies" - GatewayMCPProxyByIDPath = "/mcp-proxies/%s" - DevPortalHealthPath = "/health" + GatewayHealthPath = "/health" + GatewayAPIsPath = "/rest-apis" + GatewayAPIByIDPath = "/rest-apis/%s" + GatewayMCPProxiesPath = "/mcp-proxies" + GatewayMCPProxyByIDPath = "/mcp-proxies/%s" + GatewayLLMProvidersPath = "/llm-providers" + GatewayLLMProviderByIDPath = "/llm-providers/%s" + GatewayLLMProxiesPath = "/llm-proxies" + GatewayLLMProxyByIDPath = "/llm-proxies/%s" + DevPortalHealthPath = "/health" // API Key Endpoints (scoped to a REST API) GatewayAPIKeysPath = "/rest-apis/%s/api-keys" // %s = REST API id @@ -84,9 +88,9 @@ const ( AIWorkspaceAPIHeader = "x-wso2-api-key" // AI Workspace REST API Endpoints - AIWorkspaceLLMProvidersPath = "/api-proxy/api/v1/llm-providers" - AIWorkspaceLLMProxiesPath = "/api-proxy/api/v1/llm-proxies" - AIWorkspaceMCPProxiesPath = "/api-proxy/api/v1/mcp-proxies" + AIWorkspaceLLMProvidersPath = "/api-proxy/api/v0.9/llm-providers" + AIWorkspaceLLMProxiesPath = "/api-proxy/api/v0.9/llm-proxies" + AIWorkspaceMCPProxiesPath = "/api-proxy/api/v0.9/mcp-proxies" // Image Build Configuration GatewayVerifyChecksumOnBuild = true diff --git a/docs/cli/ai-workspace/README.md b/docs/cli/ai-workspace/README.md index 593da8bc6f..71faecc7f4 100644 --- a/docs/cli/ai-workspace/README.md +++ b/docs/cli/ai-workspace/README.md @@ -188,6 +188,26 @@ For each configured entry, the build: All resolved paths are constrained to the project directory; a path that escapes the project root fails the build for that entry. +#### Associating gateways (`metadata.yaml`) + +Optionally list the gateways the artifact can be deployed to, with per-gateway configuration overrides, in a top-level `associatedGateways` section of `metadata.yaml` (a sibling of `spec`, **not** nested under it). This applies to all artifact kinds (`LlmProxy`, `LlmProvider`, `Mcp`). Each entry is keyed by the gateway `id`. The build copies this list into the generated payload verbatim (entries without an `id` are dropped; the field is omitted entirely when absent): + +```yaml +# metadata.yaml +kind: LlmProvider +metadata: + name: wso2-claude-provider +spec: + displayName: wso2 claude provider + version: v1.0 +associatedGateways: + - id: default + configurations: + host: prod-gw.example.com +``` + +`configurations` is a free-form object — the supported keys depend on the artifact type. + ### What it generates One JSON file per configured AI-Workspace entry, written to the build output. @@ -203,6 +223,7 @@ One JSON file per configured AI-Workspace entry, written to the build output. | `provider` (`id`, `auth.{type,header,value}`) | `runtime.yaml` → `spec.provider` | | `policies[]` (`name`, `version`, `paths[].{path,methods,params}`) | `runtime.yaml` → `spec.policies` | | `openapi` | content of `definition.yaml` with `--use-spec`, otherwise empty | +| `associatedGateways[]` (`id`, `configurations`) | `metadata.yaml` → `associatedGateways` (top-level) (omitted when absent) | | `vhost` | always empty (filled in at publish time) | | `projectId` | intentionally omitted | @@ -221,6 +242,7 @@ One JSON file per configured AI-Workspace entry, written to the build output. | `rateLimiting` | the `*-ratelimit` policies (see below) | | `policies[]` (`name`, `version`, `paths[].{path,methods,params}`) | every other `runtime.yaml` → `spec.policies` entry (i.e. not `api-key-auth` or `*-ratelimit`) | | `openapi` | content of `definition.yaml` (**required** for providers) | +| `associatedGateways[]` (`id`, `configurations`) | `metadata.yaml` → `associatedGateways` (top-level) (omitted when absent) | **rateLimiting mapping.** Each policy whose name ends with `-ratelimit` becomes a rate-limiting dimension, selected by name: @@ -248,6 +270,7 @@ A limit whose path is `/*` is applied as a `global` limit for its scope; a limit | `upstream` (`main.{url,auth}`) | `runtime.yaml` → `spec.upstream` | | `policies[]` (`name`, `version`, `params`) | `runtime.yaml` → `spec.policies` (auth/authz/etc.) | | `capabilities` (`prompts`, `resources`, `tools`) | `definition.yaml` (**required** for MCP) | +| `associatedGateways[]` (`id`, `configurations`) | `metadata.yaml` → `associatedGateways` (top-level) (omitted when absent) | | `description` | empty | `definition.yaml` for an MCP proxy holds `prompts`, `resources`, and `tools`. `prompts` and `tools` are passed through unchanged; `resources` are trimmed to `uri`, `name`, and `mimeType` (any inline `text`/`blob` content is dropped). `projectId` is omitted and injected at publish time. From ec512aa01375113a7f750f87e964e307a90a65b9 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Fri, 3 Jul 2026 14:11:29 +0530 Subject: [PATCH 20/27] improve the ai workspace cli commands --- .gitignore | 2 + .vscode/launch.json | 19 +- cli/src/cmd/aiws/build.go | 218 +++++++++++++----- cli/src/cmd/aiws/build_test.go | 184 +++++++++++++++ cli/src/cmd/aiws/llmprovider/edit.go | 19 +- cli/src/cmd/aiws/llmprovider/get.go | 20 +- cli/src/cmd/aiws/llmprovider/list.go | 19 +- cli/src/cmd/aiws/llmprovider/push.go | 19 +- cli/src/cmd/aiws/llmprovider/root.go | 2 +- cli/src/cmd/aiws/llmproxy/edit.go | 16 +- cli/src/cmd/aiws/llmproxy/push.go | 18 +- cli/src/cmd/aiws/llmproxy/root.go | 2 +- cli/src/cmd/aiws/mcpproxy/edit.go | 18 +- cli/src/cmd/aiws/mcpproxy/push.go | 18 +- cli/src/cmd/aiws/mcpproxy/root.go | 2 +- cli/src/cmd/platform/remove.go | 78 +++++++ cli/src/cmd/platform/root.go | 6 +- cli/src/cmd/project/init.go | 2 +- cli/src/internal/aiworkspace/helpers.go | 167 +++++++------- cli/src/internal/aiworkspace/paths_test.go | 58 +++++ cli/src/internal/aiworkspace/print_test.go | 122 ++++++++++ cli/src/internal/config/config.go | 15 ++ .../internal/config/remove_platform_test.go | 71 ++++++ cli/src/internal/project/artifact.go | 5 +- cli/src/internal/project/scaffold.go | 2 +- cli/src/utils/constants.go | 17 +- cli/src/utils/flags.go | 1 - cli/src/utils/input.go | 46 +++- docs/cli/ai-workspace/README.md | 110 +++++---- docs/cli/end-to-end-workflow.md | 5 +- 30 files changed, 964 insertions(+), 317 deletions(-) create mode 100644 cli/src/cmd/aiws/build_test.go create mode 100644 cli/src/cmd/platform/remove.go create mode 100644 cli/src/internal/aiworkspace/paths_test.go create mode 100644 cli/src/internal/aiworkspace/print_test.go create mode 100644 cli/src/internal/config/remove_platform_test.go diff --git a/.gitignore b/.gitignore index 923b24f0ac..9410b7f69a 100644 --- a/.gitignore +++ b/.gitignore @@ -164,5 +164,7 @@ dev-policies/ gateway/target/ +portals/ai-workspace/docker-compose.yaml + # Devportal portals/developer-portal/target/ diff --git a/.vscode/launch.json b/.vscode/launch.json index f53fb6c9eb..7b1bc5f09d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -28,9 +28,22 @@ "DATABASE_DRIVER": "sqlite3", "DATABASE_DB_PATH": "${workspaceFolder}/platform-api/data/api_platform.db", "DATABASE_EXECUTE_SCHEMA_DDL": "true", - "DB_SCHEMA_PATH": "./internal/database/schema.sqlite.sql", - // JWT - skip signature validation in development - "JWT_SKIP_VALIDATION": "true", + "DB_SCHEMA_PATH": "./internal/database/schema.postgres.sql", + // Auth — local HMAC JWT, signature validation skipped for local dev + "AUTH_JWT_SKIP_VALIDATION": "true", + "AUTH_JWT_ENABLED": "true", + "AUTH_JWT_ISSUER": "platform-api", + "AUTH_IDP_ENABLED": "false", + // File-based auth — local username/password login (admin / admin). + // Mirrors [auth.file_based] in portals/ai-workspace/configs/config-platform-api.toml. + "AUTH_FILE_BASED_ENABLED": "true", + "AUTH_FILE_BASED_ORGANIZATION_ID": "99089a17-72e0-4dd8-a2f4-c8dfbb085295", + "AUTH_FILE_BASED_ORGANIZATION_NAME": "AP Organization", + "AUTH_FILE_BASED_ORGANIZATION_HANDLE": "ap-org", + "AUTH_FILE_BASED_ORGANIZATION_REGION": "us", + // Users must be a JSON *string* with lowercase keys (username/password_hash/scopes) + "AUTH_FILE_BASED_USERS": "[{\"username\":\"admin\",\"password_hash\":\"$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ.\",\"scopes\":\"ap:organization:read ap:organization:manage ap:gateway:read ap:gateway:create ap:gateway:update ap:gateway:delete ap:gateway:manage ap:gateway:token:read ap:gateway:token:create ap:gateway:token:delete ap:gateway:token:manage ap:gateway:policy:read ap:gateway:policy:create ap:gateway:policy:delete ap:gateway:policy:manage ap:gateway:artifacts:read ap:gateway:manifest:read ap:rest_api:read ap:rest_api:create ap:rest_api:update ap:rest_api:delete ap:rest_api:manage ap:rest_api:import ap:rest_api:deployment:read ap:rest_api:deployment:create ap:rest_api:deployment:delete ap:rest_api:deployment:manage ap:rest_api:api_key:read ap:rest_api:api_key:create ap:rest_api:api_key:delete ap:rest_api:api_key:manage ap:llm_provider:read ap:llm_provider:create ap:llm_provider:update ap:llm_provider:delete ap:llm_provider:manage ap:llm_provider:api_key:read ap:llm_provider:api_key:create ap:llm_provider:api_key:delete ap:llm_provider:api_key:manage ap:llm_provider:deployment:read ap:llm_provider:deployment:create ap:llm_provider:deployment:delete ap:llm_provider:deployment:manage ap:llm_proxy:read ap:llm_proxy:create ap:llm_proxy:update ap:llm_proxy:delete ap:llm_proxy:manage ap:llm_proxy:deployment:read ap:llm_proxy:deployment:create ap:llm_proxy:deployment:delete ap:llm_proxy:deployment:manage ap:mcp_proxy:read ap:mcp_proxy:create ap:mcp_proxy:update ap:mcp_proxy:delete ap:mcp_proxy:manage ap:mcp_proxy:deployment:read ap:mcp_proxy:deployment:create ap:mcp_proxy:deployment:delete ap:mcp_proxy:deployment:manage ap:application:read ap:application:create ap:application:update ap:application:delete ap:application:manage ap:subscription:read ap:subscription:create ap:subscription:update ap:subscription:delete ap:subscription:manage ap:subscription_plan:read ap:subscription_plan:create ap:subscription_plan:update ap:subscription_plan:delete ap:subscription_plan:manage ap:project:manage ap:llm_template:manage\"}]", + "PLATFORM_SECRET_ENCRYPTION_KEY": "e5e3b427bd47de988ce45128d2c00681f61e68c99123a7c1054b1d4ef4893a7b" }, }, { diff --git a/cli/src/cmd/aiws/build.go b/cli/src/cmd/aiws/build.go index e35fca83c6..b01f951edd 100644 --- a/cli/src/cmd/aiws/build.go +++ b/cli/src/cmd/aiws/build.go @@ -44,16 +44,12 @@ ap ai-ws build -f /path/to/project ap ai-ws build -o build/ # Write the generated payload to a specific file -ap ai-ws build -o build/openai.json - -# Build and fold the OpenAPI spec (definition.yaml) into the payload -ap ai-ws build --use-spec` +ap ai-ws build -o build/openai.json` ) var ( buildProjectDir string buildOutputDir string - buildUseSpec bool ) var buildCmd = &cobra.Command{ @@ -61,9 +57,9 @@ var buildCmd = &cobra.Command{ Short: "Build the project for AI workspace", Long: "Build the AI workspace artifact for the project located in the specified directory " + "(or current directory if not specified). For each ai-workspace configuration in " + - ".api-platform/config.yaml, the command reads its metadata.yaml and runtime.yaml and generates " + - "an llm-proxy creation payload as a JSON file. The openapi field is left empty by default; pass " + - "--use-spec to fold in the OpenAPI spec from definition.yaml when it exists.", + ".api-platform/config.yaml, the command reads its metadata.yaml, runtime.yaml and " + + "definition.yaml and generates a creation payload as a JSON file. The OpenAPI spec from " + + "definition.yaml is folded into the payload's openapi field.", Example: BuildCmdExample, Run: func(cmd *cobra.Command, args []string) { if err := runBuildCommand(); err != nil { @@ -76,7 +72,6 @@ var buildCmd = &cobra.Command{ func init() { utils.AddStringFlag(buildCmd, utils.FlagFile, &buildProjectDir, "", "Path to the project directory (defaults to current directory)") utils.AddStringFlag(buildCmd, utils.FlagOutput, &buildOutputDir, "", "Output path: a .json file to write the payload to, or a directory (defaults to the project build directory)") - utils.AddBoolFlag(buildCmd, utils.FlagUseSpec, &buildUseSpec, false, "Fold the OpenAPI spec (definition.yaml) into the generated payload when it exists") } // failedWorkspace records an ai-workspace config that could not be built so the @@ -158,7 +153,7 @@ func runBuildCommand() error { return fmt.Errorf("failed to create output directory: %w", err) } - outputs, failures := generateAIWorkspaceBuildArtifacts(projectRoot, outputDir, outputFile, buildUseSpec, projectConfig.AIWorkspaces) + outputs, failures := generateAIWorkspaceBuildArtifacts(projectRoot, outputDir, outputFile, projectConfig.AIWorkspaces) for _, output := range outputs { fmt.Printf("AI workspace payload generated at %s\n", output) @@ -195,13 +190,13 @@ func normalizeAIWorkspaceProjectConfig(config *project.AIWorkspaceConfig) { } } -func generateAIWorkspaceBuildArtifacts(projectRoot, outputDir, outputFile string, useSpec bool, configs []project.AIWorkspaceConfig) ([]string, []failedWorkspace) { +func generateAIWorkspaceBuildArtifacts(projectRoot, outputDir, outputFile string, configs []project.AIWorkspaceConfig) ([]string, []failedWorkspace) { outputs := make([]string, 0, len(configs)) failures := make([]failedWorkspace, 0) seen := make(map[string]string, len(configs)) // payload filename -> config name for i := range configs { - outputPath, err := buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile, useSpec, seen, &configs[i]) + outputPath, err := buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile, seen, &configs[i]) if err != nil { failures = append(failures, failedWorkspace{name: configs[i].Name, err: err}) continue @@ -213,11 +208,11 @@ func generateAIWorkspaceBuildArtifacts(projectRoot, outputDir, outputFile string } // buildSingleAIWorkspacePayload reads the metadata.yaml and runtime.yaml for one -// ai-workspace config, derives the llm-proxy creation payload, optionally folds -// in the OpenAPI spec, and writes it as JSON. When outputFile is set it is +// ai-workspace config, derives the creation payload, folds in the OpenAPI spec +// from definition.yaml, and writes it as JSON. When outputFile is set it is // written there; otherwise it lands at outputDir/.json. Any existing // file is overwritten. Returning an error drops only this config. -func buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile string, useSpec bool, seen map[string]string, config *project.AIWorkspaceConfig) (string, error) { +func buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile string, seen map[string]string, config *project.AIWorkspaceConfig) (string, error) { baseDir := resolveProjectPath(projectRoot, config.PortalRoot) if err := ensureWithinProjectRoot(projectRoot, baseDir, config.Name, "portalRoot"); err != nil { return "", err @@ -271,19 +266,14 @@ func buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile string, us return "", fmt.Errorf("ai-workspace config %q: name mismatch: metadata.yaml has metadata.name %q but runtime.yaml has metadata.name %q", config.Name, resourceName, runtimeName) } - // The payload shape and whether an OpenAPI spec is required are driven by the - // declared kind. An LlmProxy rarely needs a spec, so it stays opt-in via - // --use-spec; an LlmProvider always needs one, so the definition is required. + // The payload shape is driven by the declared kind. All kinds fold in the + // OpenAPI spec from definition.yaml, which is required. var payload interface{} switch metadataKind { case kindLLMProxy: - openapi := "" - if useSpec { - spec, err := loadAIWorkspaceSpec(projectRoot, baseDir, config, false) - if err != nil { - return "", err - } - openapi = spec + openapi, err := loadAIWorkspaceSpec(projectRoot, baseDir, config, true) + if err != nil { + return "", err } payload = buildLLMProxyPayload(resourceName, metadata, runtime, openapi) case kindLLMProvider: @@ -369,21 +359,58 @@ func loadAIWorkspaceSpec(projectRoot, baseDir string, config *project.AIWorkspac return string(data), nil } +// templateModelIDs maps an LLM provider template to the model IDs it exposes. +// When a provider's template matches a key here, the build emits a single +// modelProviders entry (keyed by the template) carrying these models. +var templateModelIDs = map[string][]string{ + "meta": { + "us.meta.llama3-3-70b-instruct-v1:0", + "us.meta.llama4-maverick-17b-instruct-v1:0", + }, + "openai": {"gpt-4o-mini", "gpt-4.1-mini", "o4-mini"}, + "anthropic": {"claude-3.5-sonnet", "claude-3-opus"}, + "google-vertex": {"gemini-1.5-pro", "gemini-1.5-flash"}, + "aws-bedrock": {"amazon.titan-text-premier", "anthropic.claude-v2"}, + "mistralai": { + "mistral-large-latest", + "mistral-small-latest", + "open-mixtral-8x22b", + }, +} + +// modelProvidersForTemplate returns the modelProviders block for a provider +// template. It returns a single model provider (keyed by the template name) +// carrying the template's models, or nil for an unknown template so the payload +// omits the field. +func modelProvidersForTemplate(template string) []llmModelProvider { + template = strings.TrimSpace(template) + modelIDs, ok := templateModelIDs[template] + if !ok || len(modelIDs) == 0 { + return nil + } + + provider := llmModelProvider{ID: template, DisplayName: template} + for _, modelID := range modelIDs { + provider.Models = append(provider.Models, llmModel{ID: modelID, DisplayName: modelID}) + } + return []llmModelProvider{provider} +} + // buildLLMProviderPayload assembles the createLLMProvider request body from the // project's metadata.yaml (name/version) and runtime.yaml (context, template, // upstream, accessControl, policies). The api-key-auth policy is mapped to the -// security block. -// -// NOTE: modelProviders and rateLimiting are not yet populated (see the -// command's pending design questions) and are omitted from the payload. +// security block, and modelProviders is derived from the template (see +// modelProvidersForTemplate). func buildLLMProviderPayload(name string, metadata aiWorkspaceMetadata, runtime aiWorkspaceRuntime, openapi string) llmProviderPayload { + template := strings.TrimSpace(runtime.Spec.Template) payload := llmProviderPayload{ ID: name, - Name: name, + DisplayName: strings.TrimSpace(metadata.Spec.DisplayName), Version: strings.TrimSpace(metadata.Spec.Version), Context: strings.TrimSpace(runtime.Spec.Context), - Template: strings.TrimSpace(runtime.Spec.Template), + Template: template, OpenAPI: openapi, + ModelProviders: modelProvidersForTemplate(template), AssociatedGateways: normalizeAssociatedGateways(metadata.AssociatedGateways), } @@ -442,7 +469,7 @@ func mapPolicy(policy runtimeProviderPolicy) llmPolicy { func buildMCPProxyPayload(name string, metadata aiWorkspaceMetadata, runtime aiWorkspaceRuntime, definition mcpDefinition) mcpProxyPayload { payload := mcpProxyPayload{ ID: name, - Name: name, + DisplayName: strings.TrimSpace(metadata.Spec.DisplayName), Version: strings.TrimSpace(metadata.Spec.Version), Context: strings.TrimSpace(runtime.Spec.Context), Description: "", @@ -775,38 +802,86 @@ func buildSecurityFromPolicies(policies []runtimeProviderPolicy) *securityConfig return nil } +// defaultProxyDescription is used when runtime.yaml carries no spec.description. +const defaultProxyDescription = "No description provided for this proxy." + // buildLLMProxyPayload assembles the createLLMProxy request body from the -// project's metadata.yaml (name/version) and runtime.yaml (context, provider, -// policies). projectId is intentionally omitted and vhost is left empty for the -// caller to fill in at publish time. +// project's metadata.yaml (name/version/displayName) and runtime.yaml (context, +// provider, description, policies). Policies come from runtime.yaml's split +// globalPolicies / operationPolicies sections: the api-key-auth global policy is +// mapped to the security block, every other global policy passes through into +// globalPolicies, and operationPolicies pass through with their per-path params. +// Each policy's params are policy-specific (no common schema) and are copied +// verbatim. projectId is intentionally omitted for the caller to inject at +// publish time. func buildLLMProxyPayload(proxyName string, metadata aiWorkspaceMetadata, runtime aiWorkspaceRuntime, openapi string) llmProxyPayload { + description := strings.TrimSpace(runtime.Spec.Description) + if description == "" { + description = defaultProxyDescription + } + payload := llmProxyPayload{ ID: proxyName, - Name: proxyName, + DisplayName: strings.TrimSpace(metadata.Spec.DisplayName), Version: strings.TrimSpace(metadata.Spec.Version), Context: strings.TrimSpace(runtime.Spec.Context), - Vhost: "", + Description: description, OpenAPI: openapi, + ReadOnly: false, Provider: llmProxyProvider{ID: strings.TrimSpace(runtime.Spec.Provider.ID)}, - Policies: []llmPolicy{}, AssociatedGateways: normalizeAssociatedGateways(metadata.AssociatedGateways), } + // The proxy references its provider by id; the provider owns the credential + // value, so only the auth type/header are carried here (never the secret). if auth := runtime.Spec.Provider.Auth; auth != nil { payload.Provider.Auth = &llmUpstreamAuth{ Type: auth.Type, Header: auth.Header, - Value: auth.Value, } } - for _, policy := range runtime.Spec.Policies { - payload.Policies = append(payload.Policies, mapPolicy(policy)) + // api-key-auth is expressed as the security block; all other global policies + // pass through with their policy-specific params. + payload.Security = buildSecurityFromGlobalPolicies(runtime.Spec.GlobalPolicies) + for _, policy := range runtime.Spec.GlobalPolicies { + if policy.Name == "api-key-auth" { + continue + } + payload.GlobalPolicies = append(payload.GlobalPolicies, llmGlobalPolicy{ + Name: policy.Name, + Version: policy.Version, + Params: policy.Params, + }) + } + + for _, policy := range runtime.Spec.OperationPolicies { + payload.OperationPolicies = append(payload.OperationPolicies, mapPolicy(policy)) } return payload } +// buildSecurityFromGlobalPolicies maps the api-key-auth global policy (if +// present) to the proxy's security block. Its params sit at the policy level +// (in, key), unlike the provider's paths-based api-key-auth policy. +func buildSecurityFromGlobalPolicies(policies []runtimeProviderPolicy) *securityConfig { + for _, policy := range policies { + if policy.Name != "api-key-auth" { + continue + } + apiKey := &apiKeySecurity{Enabled: true} + if v, ok := policy.Params["key"].(string); ok { + apiKey.Key = v + } + if v, ok := policy.Params["in"].(string); ok { + apiKey.In = v + } + return &securityConfig{Enabled: true, APIKey: apiKey} + } + return nil +} + func payloadFileName(name string) string { sanitized := strings.TrimSpace(name) sanitized = strings.ReplaceAll(sanitized, string(os.PathSeparator), "-") @@ -859,15 +934,21 @@ type aiWorkspaceRuntime struct { Name string `yaml:"name"` } `yaml:"metadata"` Spec struct { - DisplayName string `yaml:"displayName"` - Version string `yaml:"version"` - Context string `yaml:"context"` - Template string `yaml:"template"` - SpecVersion string `yaml:"specVersion"` - Provider runtimeProvider `yaml:"provider"` - Upstream *runtimeUpstream `yaml:"upstream"` - AccessControl *runtimeAccessControl `yaml:"accessControl"` - Policies []runtimeProviderPolicy `yaml:"policies"` + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` + Context string `yaml:"context"` + Description string `yaml:"description"` + Template string `yaml:"template"` + SpecVersion string `yaml:"specVersion"` + Provider runtimeProvider `yaml:"provider"` + Upstream *runtimeUpstream `yaml:"upstream"` + AccessControl *runtimeAccessControl `yaml:"accessControl"` + // Policies is the legacy flat list still used by the LLM provider and MCP + // proxy builders. LLM proxies use the split globalPolicies / + // operationPolicies below. + Policies []runtimeProviderPolicy `yaml:"policies"` + GlobalPolicies []runtimeProviderPolicy `yaml:"globalPolicies"` + OperationPolicies []runtimeProviderPolicy `yaml:"operationPolicies"` } `yaml:"spec"` } @@ -916,16 +997,28 @@ type runtimePolicyPath struct { type llmProxyPayload struct { ID string `json:"id"` - Name string `json:"name"` + DisplayName string `json:"displayName"` Version string `json:"version"` Context string `json:"context,omitempty"` - Vhost string `json:"vhost"` + Description string `json:"description"` Provider llmProxyProvider `json:"provider"` OpenAPI string `json:"openapi"` - Policies []llmPolicy `json:"policies"` + ReadOnly bool `json:"readOnly"` + Security *securityConfig `json:"security,omitempty"` + GlobalPolicies []llmGlobalPolicy `json:"globalPolicies,omitempty"` + OperationPolicies []llmPolicy `json:"operationPolicies,omitempty"` AssociatedGateways []associatedGateway `json:"associatedGateways,omitempty"` } +// llmGlobalPolicy is an api-level policy applied across all operations. Unlike +// operation policies it has no paths; its params are policy-specific and passed +// through verbatim. +type llmGlobalPolicy struct { + Name string `json:"name"` + Version string `json:"version"` + Params map[string]interface{} `json:"params,omitempty"` +} + type llmProxyProvider struct { ID string `json:"id"` Auth *llmUpstreamAuth `json:"auth,omitempty"` @@ -961,7 +1054,7 @@ type mcpDefinition struct { type mcpProxyPayload struct { ID string `json:"id"` - Name string `json:"name"` + DisplayName string `json:"displayName"` Version string `json:"version"` Context string `json:"context,omitempty"` Description string `json:"description"` @@ -988,10 +1081,11 @@ type mcpPolicy struct { type llmProviderPayload struct { ID string `json:"id"` - Name string `json:"name"` + DisplayName string `json:"displayName"` Version string `json:"version"` Context string `json:"context,omitempty"` Template string `json:"template"` + ModelProviders []llmModelProvider `json:"modelProviders,omitempty"` Upstream *llmUpstream `json:"upstream,omitempty"` AccessControl *llmAccessControl `json:"accessControl,omitempty"` OpenAPI string `json:"openapi"` @@ -1001,6 +1095,20 @@ type llmProviderPayload struct { AssociatedGateways []associatedGateway `json:"associatedGateways,omitempty"` } +// llmModelProvider / llmModel mirror the LLMModelProvider / LLMModel schemas +// (openapi.yaml). The build derives them from the provider template. +type llmModelProvider struct { + ID string `json:"id,omitempty"` + DisplayName string `json:"displayName"` + Models []llmModel `json:"models,omitempty"` +} + +type llmModel struct { + ID string `json:"id,omitempty"` + DisplayName string `json:"displayName"` + Description string `json:"description,omitempty"` +} + type llmRateLimiting struct { ProviderLevel *rateLimitScope `json:"providerLevel,omitempty"` ConsumerLevel *rateLimitScope `json:"consumerLevel,omitempty"` diff --git a/cli/src/cmd/aiws/build_test.go b/cli/src/cmd/aiws/build_test.go new file mode 100644 index 0000000000..cc8a21850a --- /dev/null +++ b/cli/src/cmd/aiws/build_test.go @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import "testing" + +func newProxyRuntime() aiWorkspaceRuntime { + var rt aiWorkspaceRuntime + rt.Spec.Context = "/default/claude-proxy2" + rt.Spec.Provider = runtimeProvider{ + ID: "wso2-claude-provider", + Auth: &runtimeProviderAuth{Type: "api-key", Header: "X-API-Key", Value: "{{ secret \"abc\" }}"}, + } + rt.Spec.GlobalPolicies = []runtimeProviderPolicy{ + {Name: "api-key-auth", Version: "", Params: map[string]interface{}{"in": "header", "key": "X-API-Key"}}, + {Name: "aws-bedrock-guardrail", Version: "v1", Params: map[string]interface{}{ + "request": map[string]interface{}{"enabled": true, "jsonPath": "$.messages[-1].content"}, + }}, + } + rt.Spec.OperationPolicies = []runtimeProviderPolicy{ + {Name: "basic-auth", Version: "v1", Paths: []runtimePolicyPath{ + {Path: "/v1/messages", Methods: []string{"POST"}, Params: map[string]interface{}{"username": "admin", "password": "admin"}}, + }}, + } + return rt +} + +func newProxyMetadata() aiWorkspaceMetadata { + var md aiWorkspaceMetadata + md.Metadata.Name = "claude-proxy2" + md.Spec.DisplayName = "claude proxy2" + md.Spec.Version = "v1.0" + return md +} + +func TestBuildLLMProxyPayload_MapsGlobalAndOperationPolicies(t *testing.T) { + payload := buildLLMProxyPayload("claude-proxy2", newProxyMetadata(), newProxyRuntime(), "") + + if payload.ID != "claude-proxy2" || payload.DisplayName != "claude proxy2" || payload.Version != "v1.0" { + t.Fatalf("unexpected identity fields: %+v", payload) + } + if payload.Description != defaultProxyDescription { + t.Fatalf("expected default description, got %q", payload.Description) + } + + // api-key-auth is lifted into security, not left in globalPolicies. + if payload.Security == nil || payload.Security.APIKey == nil { + t.Fatalf("expected security block from api-key-auth, got %+v", payload.Security) + } + if !payload.Security.Enabled || payload.Security.APIKey.In != "header" || payload.Security.APIKey.Key != "X-API-Key" { + t.Fatalf("unexpected security block: %+v", payload.Security.APIKey) + } + + // Remaining global policies pass through with their free-form params intact. + if len(payload.GlobalPolicies) != 1 || payload.GlobalPolicies[0].Name != "aws-bedrock-guardrail" { + t.Fatalf("expected 1 global policy (aws-bedrock-guardrail), got %+v", payload.GlobalPolicies) + } + req, ok := payload.GlobalPolicies[0].Params["request"].(map[string]interface{}) + if !ok || req["jsonPath"] != "$.messages[-1].content" { + t.Fatalf("global policy params not preserved verbatim: %+v", payload.GlobalPolicies[0].Params) + } + + // Operation policies keep their per-path free-form params. + if len(payload.OperationPolicies) != 1 || payload.OperationPolicies[0].Name != "basic-auth" { + t.Fatalf("expected 1 operation policy (basic-auth), got %+v", payload.OperationPolicies) + } + op := payload.OperationPolicies[0] + if len(op.Paths) != 1 || op.Paths[0].Path != "/v1/messages" || op.Paths[0].Params["username"] != "admin" { + t.Fatalf("operation policy path/params not preserved: %+v", op.Paths) + } + + // The provider auth carries type/header but never the secret value. + if payload.Provider.Auth == nil || payload.Provider.Auth.Value != "" { + t.Fatalf("expected provider auth without secret value, got %+v", payload.Provider.Auth) + } +} + +func TestBuildLLMProxyPayload_UsesRuntimeDescriptionWhenSet(t *testing.T) { + rt := newProxyRuntime() + rt.Spec.Description = "custom proxy description" + payload := buildLLMProxyPayload("claude-proxy2", newProxyMetadata(), rt, "") + if payload.Description != "custom proxy description" { + t.Fatalf("expected runtime description, got %q", payload.Description) + } +} + +func TestBuildLLMProxyPayload_OmitsPoliciesWhenNone(t *testing.T) { + var md aiWorkspaceMetadata + md.Spec.DisplayName = "p" + md.Spec.Version = "v1.0" + payload := buildLLMProxyPayload("p", md, aiWorkspaceRuntime{}, "") + if payload.Security != nil { + t.Fatalf("expected no security block, got %+v", payload.Security) + } + if payload.GlobalPolicies != nil || payload.OperationPolicies != nil { + t.Fatalf("expected no policies, got global=%+v operation=%+v", payload.GlobalPolicies, payload.OperationPolicies) + } +} + +func TestModelProvidersForTemplate_KnownTemplate(t *testing.T) { + got := modelProvidersForTemplate("openai") + if len(got) != 1 { + t.Fatalf("expected 1 model provider, got %d", len(got)) + } + provider := got[0] + if provider.ID != "openai" || provider.DisplayName != "openai" { + t.Fatalf("expected provider id/displayName %q, got id=%q displayName=%q", "openai", provider.ID, provider.DisplayName) + } + + wantModels := []string{"gpt-4o-mini", "gpt-4.1-mini", "o4-mini"} + if len(provider.Models) != len(wantModels) { + t.Fatalf("expected %d models, got %d", len(wantModels), len(provider.Models)) + } + for i, want := range wantModels { + if provider.Models[i].ID != want || provider.Models[i].DisplayName != want { + t.Fatalf("model[%d]: expected id/displayName %q, got id=%q displayName=%q", i, want, provider.Models[i].ID, provider.Models[i].DisplayName) + } + } +} + +func TestModelProvidersForTemplate_TrimsAndAllTemplates(t *testing.T) { + // Every documented template maps to a non-empty model provider, and the + // template is trimmed before lookup. + for _, template := range []string{"meta", "openai", "anthropic", "google-vertex", "aws-bedrock", "mistralai"} { + if got := modelProvidersForTemplate(" " + template + " "); len(got) != 1 || len(got[0].Models) == 0 { + t.Fatalf("template %q: expected one provider with models, got %#v", template, got) + } + } +} + +func TestModelProvidersForTemplate_UnknownTemplateOmitted(t *testing.T) { + if got := modelProvidersForTemplate("custom-template"); got != nil { + t.Fatalf("expected nil for unknown template, got %#v", got) + } + if got := modelProvidersForTemplate(""); got != nil { + t.Fatalf("expected nil for empty template, got %#v", got) + } +} + +func TestBuildLLMProviderPayload_IncludesModelProviders(t *testing.T) { + var metadata aiWorkspaceMetadata + metadata.Metadata.Name = "wso2-claude-provider" + metadata.Spec.Version = "v1.0" + + var runtime aiWorkspaceRuntime + runtime.Spec.Template = "anthropic" + + payload := buildLLMProviderPayload("wso2-claude-provider", metadata, runtime, "") + if len(payload.ModelProviders) != 1 { + t.Fatalf("expected modelProviders populated for template %q, got %#v", runtime.Spec.Template, payload.ModelProviders) + } + if payload.ModelProviders[0].ID != "anthropic" { + t.Fatalf("expected model provider id %q, got %q", "anthropic", payload.ModelProviders[0].ID) + } +} + +func TestBuildLLMProviderPayload_OmitsModelProvidersForUnknownTemplate(t *testing.T) { + var metadata aiWorkspaceMetadata + metadata.Spec.Version = "v1.0" + + var runtime aiWorkspaceRuntime + runtime.Spec.Template = "my-custom-template" + + payload := buildLLMProviderPayload("p", metadata, runtime, "") + if payload.ModelProviders != nil { + t.Fatalf("expected no modelProviders for unknown template, got %#v", payload.ModelProviders) + } +} diff --git a/cli/src/cmd/aiws/llmprovider/edit.go b/cli/src/cmd/aiws/llmprovider/edit.go index 67d5d81d3c..5dca818eea 100644 --- a/cli/src/cmd/aiws/llmprovider/edit.go +++ b/cli/src/cmd/aiws/llmprovider/edit.go @@ -33,15 +33,14 @@ import ( const ( EditCmdLiteral = "edit" EditCmdExample = `# Update an existing LLM provider using the active AI workspace -ap ai-ws llm-provider edit -f build/wso2-claude.json --org +ap ai-ws llm-provider edit -f build/wso2-claude.json # Update using a specific AI workspace -ap ai-ws llm-provider edit -f build/wso2-claude.json --org --display-name my-workspace --platform eu` +ap ai-ws llm-provider edit -f build/wso2-claude.json --display-name my-workspace --platform eu` ) var ( editFilePath string - editOrgID string editName string editPlatform string editInsecure bool @@ -63,21 +62,14 @@ var editCmd = &cobra.Command{ func init() { utils.AddStringFlag(editCmd, utils.FlagFile, &editFilePath, "", "Path to the LLM provider payload JSON file (required)") - utils.AddStringFlag(editCmd, utils.FlagOrgID, &editOrgID, "", "Organization ID (required)") utils.AddStringFlag(editCmd, utils.FlagName, &editName, "", "AI workspace display name") utils.AddStringFlag(editCmd, utils.FlagPlatform, &editPlatform, "", "Platform name") utils.AddStringFlag(editCmd, utils.FlagOutput, &editOutput, "", "Output format: \"json\" prints the full server response (default: summary)") editCmd.Flags().BoolVar(&editInsecure, "insecure", false, "Skip TLS certificate verification") _ = editCmd.MarkFlagRequired(utils.FlagFile) - _ = editCmd.MarkFlagRequired(utils.FlagOrgID) } func runEditCommand() error { - orgID := strings.TrimSpace(editOrgID) - if orgID == "" { - return fmt.Errorf("organization ID is required") - } - payload, err := aiworkspace.ReadJSONFile(editFilePath) if err != nil { return err @@ -99,17 +91,16 @@ func runEditCommand() error { return fmt.Errorf("failed to load config: %w", err) } - aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, editName, editPlatform) + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, editName, editPlatform) if err != nil { return err } client := aiworkspace.NewClientWithOptions(aiWorkspace, editInsecure) - resp, err := client.PutJSON(aiworkspace.ProviderResourcePath(orgID, providerID), payload) + resp, err := client.PutJSON(aiworkspace.ProviderByIDPath(providerID), payload) if err != nil { return aiworkspace.WrapRequestError("update llm provider", err, editInsecure) } - return aiworkspace.PrintArtifactResult(resp, editOutput, providerID, - fmt.Sprintf("LLM provider updated on ai-workspace %s (platform: %s)", aiWorkspace.Name, resolvedPlatform)) + return aiworkspace.PrintApplyResult(resp, editOutput, "LlmProvider", "updated", providerID, "") } diff --git a/cli/src/cmd/aiws/llmprovider/get.go b/cli/src/cmd/aiws/llmprovider/get.go index 67799b3557..0fb7ab0e21 100644 --- a/cli/src/cmd/aiws/llmprovider/get.go +++ b/cli/src/cmd/aiws/llmprovider/get.go @@ -31,19 +31,18 @@ import ( const ( GetCmdLiteral = "get" - GetCmdExample = `# List all LLM providers in an organization -ap ai-ws llm-provider get --org + GetCmdExample = `# List all LLM providers +ap ai-ws llm-provider get # Get a single LLM provider by ID -ap ai-ws llm-provider get --org --id wso2-claude +ap ai-ws llm-provider get --id wso2-claude # List using a specific AI workspace with pagination -ap ai-ws llm-provider get --org --limit 50 --offset 0 --display-name my-workspace --platform eu` +ap ai-ws llm-provider get --limit 50 --offset 0 --display-name my-workspace --platform eu` ) var ( getID string - getOrgID string getLimit string getOffset string getName string @@ -66,21 +65,14 @@ var getCmd = &cobra.Command{ func init() { utils.AddStringFlag(getCmd, utils.FlagID, &getID, "", "LLM provider ID (omit to list all)") - utils.AddStringFlag(getCmd, utils.FlagOrgID, &getOrgID, "", "Organization ID (required)") utils.AddStringFlag(getCmd, utils.FlagLimit, &getLimit, "", "Maximum number of providers to return when listing") utils.AddStringFlag(getCmd, utils.FlagOffset, &getOffset, "", "Number of providers to skip when listing") utils.AddStringFlag(getCmd, utils.FlagName, &getName, "", "AI workspace display name") utils.AddStringFlag(getCmd, utils.FlagPlatform, &getPlatform, "", "Platform name") getCmd.Flags().BoolVar(&getInsecure, "insecure", false, "Skip TLS certificate verification") - _ = getCmd.MarkFlagRequired(utils.FlagOrgID) } func runGetCommand() error { - orgID := strings.TrimSpace(getOrgID) - if orgID == "" { - return fmt.Errorf("organization ID is required") - } - cfg, err := config.LoadConfig() if err != nil { return fmt.Errorf("failed to load config: %w", err) @@ -95,10 +87,10 @@ func runGetCommand() error { var path, action string if id := strings.TrimSpace(getID); id != "" { - path = aiworkspace.ProviderResourcePath(orgID, id) + path = aiworkspace.ProviderByIDPath(id) action = "get llm provider" } else { - path = aiworkspace.ProviderListPath(orgID, aiworkspace.ListQuery{Limit: getLimit, Offset: getOffset}) + path = aiworkspace.ProviderListPath(aiworkspace.ListQuery{Limit: getLimit, Offset: getOffset}) action = "list llm providers" } diff --git a/cli/src/cmd/aiws/llmprovider/list.go b/cli/src/cmd/aiws/llmprovider/list.go index 3175328955..5b206fbab8 100644 --- a/cli/src/cmd/aiws/llmprovider/list.go +++ b/cli/src/cmd/aiws/llmprovider/list.go @@ -21,7 +21,6 @@ package llmprovider import ( "fmt" "os" - "strings" "github.com/spf13/cobra" "github.com/wso2/api-platform/cli/internal/aiworkspace" @@ -31,18 +30,17 @@ import ( const ( ListCmdLiteral = "list" - ListCmdExample = `# List all LLM providers in an organization -ap ai-ws llm-provider list --org + ListCmdExample = `# List all LLM providers +ap ai-ws llm-provider list # List with pagination -ap ai-ws llm-provider list --org --limit 50 --offset 0 +ap ai-ws llm-provider list --limit 50 --offset 0 # List using a specific AI workspace -ap ai-ws llm-provider list --org --display-name my-workspace --platform eu` +ap ai-ws llm-provider list --display-name my-workspace --platform eu` ) var ( - listOrgID string listLimit string listOffset string listName string @@ -64,21 +62,14 @@ var listCmd = &cobra.Command{ } func init() { - utils.AddStringFlag(listCmd, utils.FlagOrgID, &listOrgID, "", "Organization ID (required)") utils.AddStringFlag(listCmd, utils.FlagLimit, &listLimit, "", "Maximum number of providers to return") utils.AddStringFlag(listCmd, utils.FlagOffset, &listOffset, "", "Number of providers to skip") utils.AddStringFlag(listCmd, utils.FlagName, &listName, "", "AI workspace display name") utils.AddStringFlag(listCmd, utils.FlagPlatform, &listPlatform, "", "Platform name") listCmd.Flags().BoolVar(&listInsecure, "insecure", false, "Skip TLS certificate verification") - _ = listCmd.MarkFlagRequired(utils.FlagOrgID) } func runListCommand() error { - orgID := strings.TrimSpace(listOrgID) - if orgID == "" { - return fmt.Errorf("organization ID is required") - } - cfg, err := config.LoadConfig() if err != nil { return fmt.Errorf("failed to load config: %w", err) @@ -90,7 +81,7 @@ func runListCommand() error { } client := aiworkspace.NewClientWithOptions(aiWorkspace, listInsecure) - path := aiworkspace.ProviderListPath(orgID, aiworkspace.ListQuery{Limit: listLimit, Offset: listOffset}) + path := aiworkspace.ProviderListPath(aiworkspace.ListQuery{Limit: listLimit, Offset: listOffset}) resp, err := client.Get(path) if err != nil { diff --git a/cli/src/cmd/aiws/llmprovider/push.go b/cli/src/cmd/aiws/llmprovider/push.go index 35336980bb..42cc85aedc 100644 --- a/cli/src/cmd/aiws/llmprovider/push.go +++ b/cli/src/cmd/aiws/llmprovider/push.go @@ -33,15 +33,14 @@ import ( const ( PushCmdLiteral = "push" PushCmdExample = `# Push an LLM provider artifact using the active AI workspace -ap ai-ws llm-provider push -f build/wso2-claude.json --org +ap ai-ws llm-provider push -f build/wso2-claude.json # Push using a specific AI workspace -ap ai-ws llm-provider push -f build/wso2-claude.json --org --display-name my-workspace --platform eu` +ap ai-ws llm-provider push -f build/wso2-claude.json --display-name my-workspace --platform eu` ) var ( pushFilePath string - pushOrgID string pushName string pushPlatform string pushInsecure bool @@ -63,21 +62,14 @@ var pushCmd = &cobra.Command{ func init() { utils.AddStringFlag(pushCmd, utils.FlagFile, &pushFilePath, "", "Path to the LLM provider payload JSON file (required)") - utils.AddStringFlag(pushCmd, utils.FlagOrgID, &pushOrgID, "", "Organization ID (required)") utils.AddStringFlag(pushCmd, utils.FlagName, &pushName, "", "AI workspace display name") utils.AddStringFlag(pushCmd, utils.FlagPlatform, &pushPlatform, "", "Platform name") utils.AddStringFlag(pushCmd, utils.FlagOutput, &pushOutput, "", "Output format: \"json\" prints the full server response (default: summary)") pushCmd.Flags().BoolVar(&pushInsecure, "insecure", false, "Skip TLS certificate verification") _ = pushCmd.MarkFlagRequired(utils.FlagFile) - _ = pushCmd.MarkFlagRequired(utils.FlagOrgID) } func runPushCommand() error { - orgID := strings.TrimSpace(pushOrgID) - if orgID == "" { - return fmt.Errorf("organization ID is required") - } - payload, err := aiworkspace.ReadJSONFile(pushFilePath) if err != nil { return err @@ -101,17 +93,16 @@ func runPushCommand() error { return fmt.Errorf("failed to load config: %w", err) } - aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, pushName, pushPlatform) + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, pushName, pushPlatform) if err != nil { return err } client := aiworkspace.NewClientWithOptions(aiWorkspace, pushInsecure) - resp, err := client.PostJSON(aiworkspace.ProviderPath(orgID), payload) + resp, err := client.PostJSON(aiworkspace.ProviderPath(), payload) if err != nil { return aiworkspace.WrapRequestError("push llm provider", err, pushInsecure) } - return aiworkspace.PrintArtifactResult(resp, pushOutput, providerID, - fmt.Sprintf("LLM provider pushed to ai-workspace %s (platform: %s)", aiWorkspace.Name, resolvedPlatform)) + return aiworkspace.PrintApplyResult(resp, pushOutput, "LlmProvider", "applied", providerID, "") } diff --git a/cli/src/cmd/aiws/llmprovider/root.go b/cli/src/cmd/aiws/llmprovider/root.go index 5f678c75d2..99ebf0fde4 100644 --- a/cli/src/cmd/aiws/llmprovider/root.go +++ b/cli/src/cmd/aiws/llmprovider/root.go @@ -25,7 +25,7 @@ import ( const ( LLMProviderCmdLiteral = "llm-provider" LLMProviderCmdExample = `# Push an LLM provider artifact to the AI workspace -ap ai-ws llm-provider push -f build/wso2-claude.json --org ` +ap ai-ws llm-provider push -f build/wso2-claude.json` ) // LLMProviderCmd is the parent command for LLM provider operations. diff --git a/cli/src/cmd/aiws/llmproxy/edit.go b/cli/src/cmd/aiws/llmproxy/edit.go index 483d56bcb5..f291b93322 100644 --- a/cli/src/cmd/aiws/llmproxy/edit.go +++ b/cli/src/cmd/aiws/llmproxy/edit.go @@ -33,15 +33,14 @@ import ( const ( EditCmdLiteral = "edit" EditCmdExample = `# Update an existing LLM proxy using the active AI workspace -ap ai-ws llm-proxy edit -f build/wso2-openai-proxy.json --org --project-id +ap ai-ws llm-proxy edit -f build/wso2-openai-proxy.json --project-id # Update using a specific AI workspace -ap ai-ws llm-proxy edit -f build/wso2-openai-proxy.json --org --project-id --display-name my-workspace --platform eu` +ap ai-ws llm-proxy edit -f build/wso2-openai-proxy.json --project-id --display-name my-workspace --platform eu` ) var ( editFilePath string - editOrgID string editProjectID string editName string editPlatform string @@ -64,22 +63,16 @@ var editCmd = &cobra.Command{ func init() { utils.AddStringFlag(editCmd, utils.FlagFile, &editFilePath, "", "Path to the LLM proxy payload JSON file (required)") - utils.AddStringFlag(editCmd, utils.FlagOrgID, &editOrgID, "", "Organization ID (required)") utils.AddStringFlag(editCmd, utils.FlagProjectID, &editProjectID, "", "Project ID to set on the payload (required)") utils.AddStringFlag(editCmd, utils.FlagName, &editName, "", "AI workspace display name") utils.AddStringFlag(editCmd, utils.FlagPlatform, &editPlatform, "", "Platform name") utils.AddStringFlag(editCmd, utils.FlagOutput, &editOutput, "", "Output format: \"json\" prints the full server response (default: summary)") editCmd.Flags().BoolVar(&editInsecure, "insecure", false, "Skip TLS certificate verification") _ = editCmd.MarkFlagRequired(utils.FlagFile) - _ = editCmd.MarkFlagRequired(utils.FlagOrgID) _ = editCmd.MarkFlagRequired(utils.FlagProjectID) } func runEditCommand() error { - orgID := strings.TrimSpace(editOrgID) - if orgID == "" { - return fmt.Errorf("organization ID is required") - } projectID := strings.TrimSpace(editProjectID) if projectID == "" { return fmt.Errorf("project ID is required") @@ -115,7 +108,7 @@ func runEditCommand() error { return fmt.Errorf("failed to load config: %w", err) } - aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, editName, editPlatform) + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, editName, editPlatform) if err != nil { return err } @@ -126,6 +119,5 @@ func runEditCommand() error { return aiworkspace.WrapRequestError("update llm proxy", err, editInsecure) } - return aiworkspace.PrintArtifactResult(resp, editOutput, proxyID, - fmt.Sprintf("LLM proxy updated on ai-workspace %s (platform: %s)", aiWorkspace.Name, resolvedPlatform)) + return aiworkspace.PrintApplyResult(resp, editOutput, "LlmProxy", "updated", proxyID, projectID) } diff --git a/cli/src/cmd/aiws/llmproxy/push.go b/cli/src/cmd/aiws/llmproxy/push.go index 673f6b3c28..e346bf66fb 100644 --- a/cli/src/cmd/aiws/llmproxy/push.go +++ b/cli/src/cmd/aiws/llmproxy/push.go @@ -33,15 +33,14 @@ import ( const ( PushCmdLiteral = "push" PushCmdExample = `# Push an LLM proxy artifact using the active AI workspace -ap ai-ws llm-proxy push -f build/wso2-openai-proxy.json --org --project-id +ap ai-ws llm-proxy push -f build/wso2-openai-proxy.json --project-id # Push using a specific AI workspace -ap ai-ws llm-proxy push -f build/wso2-openai-proxy.json --org --project-id --display-name my-workspace --platform eu` +ap ai-ws llm-proxy push -f build/wso2-openai-proxy.json --project-id --display-name my-workspace --platform eu` ) var ( pushFilePath string - pushOrgID string pushProjectID string pushName string pushPlatform string @@ -64,22 +63,16 @@ var pushCmd = &cobra.Command{ func init() { utils.AddStringFlag(pushCmd, utils.FlagFile, &pushFilePath, "", "Path to the LLM proxy payload JSON file (required)") - utils.AddStringFlag(pushCmd, utils.FlagOrgID, &pushOrgID, "", "Organization ID (required)") utils.AddStringFlag(pushCmd, utils.FlagProjectID, &pushProjectID, "", "Project ID to set on the payload (required)") utils.AddStringFlag(pushCmd, utils.FlagName, &pushName, "", "AI workspace display name") utils.AddStringFlag(pushCmd, utils.FlagPlatform, &pushPlatform, "", "Platform name") utils.AddStringFlag(pushCmd, utils.FlagOutput, &pushOutput, "", "Output format: \"json\" prints the full server response (default: summary)") pushCmd.Flags().BoolVar(&pushInsecure, "insecure", false, "Skip TLS certificate verification") _ = pushCmd.MarkFlagRequired(utils.FlagFile) - _ = pushCmd.MarkFlagRequired(utils.FlagOrgID) _ = pushCmd.MarkFlagRequired(utils.FlagProjectID) } func runPushCommand() error { - orgID := strings.TrimSpace(pushOrgID) - if orgID == "" { - return fmt.Errorf("organization ID is required") - } projectID := strings.TrimSpace(pushProjectID) if projectID == "" { return fmt.Errorf("project ID is required") @@ -115,17 +108,16 @@ func runPushCommand() error { return fmt.Errorf("failed to load config: %w", err) } - aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, pushName, pushPlatform) + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, pushName, pushPlatform) if err != nil { return err } client := aiworkspace.NewClientWithOptions(aiWorkspace, pushInsecure) - resp, err := client.PostJSON(aiworkspace.ProxyPath(orgID), body) + resp, err := client.PostJSON(aiworkspace.ProxyPath(), body) if err != nil { return aiworkspace.WrapRequestError("push llm proxy", err, pushInsecure) } - return aiworkspace.PrintArtifactResult(resp, pushOutput, proxyID, - fmt.Sprintf("LLM proxy pushed to ai-workspace %s (platform: %s)", aiWorkspace.Name, resolvedPlatform)) + return aiworkspace.PrintApplyResult(resp, pushOutput, "LlmProxy", "applied", proxyID, projectID) } diff --git a/cli/src/cmd/aiws/llmproxy/root.go b/cli/src/cmd/aiws/llmproxy/root.go index 3af3663007..a92caa5eef 100644 --- a/cli/src/cmd/aiws/llmproxy/root.go +++ b/cli/src/cmd/aiws/llmproxy/root.go @@ -25,7 +25,7 @@ import ( const ( LLMProxyCmdLiteral = "llm-proxy" LLMProxyCmdExample = `# Push an LLM proxy artifact to the AI workspace -ap ai-ws llm-proxy push -f build/wso2-openai-proxy.json --org --project-id ` +ap ai-ws llm-proxy push -f build/wso2-openai-proxy.json --project-id ` ) // LLMProxyCmd is the parent command for LLM proxy operations. diff --git a/cli/src/cmd/aiws/mcpproxy/edit.go b/cli/src/cmd/aiws/mcpproxy/edit.go index 0febab4cf5..8754a22e75 100644 --- a/cli/src/cmd/aiws/mcpproxy/edit.go +++ b/cli/src/cmd/aiws/mcpproxy/edit.go @@ -33,15 +33,14 @@ import ( const ( EditCmdLiteral = "edit" EditCmdExample = `# Update an existing MCP proxy using the active AI workspace -ap ai-ws mcp-proxy edit -f build/bijira-mcp-everything.json --org --project-id +ap ai-ws mcp-proxy edit -f build/bijira-mcp-everything.json --project-id # Update using a specific AI workspace -ap ai-ws mcp-proxy edit -f build/bijira-mcp-everything.json --org --project-id --display-name my-workspace --platform eu` +ap ai-ws mcp-proxy edit -f build/bijira-mcp-everything.json --project-id --display-name my-workspace --platform eu` ) var ( editFilePath string - editOrgID string editProjectID string editName string editPlatform string @@ -64,22 +63,16 @@ var editCmd = &cobra.Command{ func init() { utils.AddStringFlag(editCmd, utils.FlagFile, &editFilePath, "", "Path to the MCP proxy payload JSON file (required)") - utils.AddStringFlag(editCmd, utils.FlagOrgID, &editOrgID, "", "Organization ID (required)") utils.AddStringFlag(editCmd, utils.FlagProjectID, &editProjectID, "", "Project ID to set on the payload (required)") utils.AddStringFlag(editCmd, utils.FlagName, &editName, "", "AI workspace display name") utils.AddStringFlag(editCmd, utils.FlagPlatform, &editPlatform, "", "Platform name") utils.AddStringFlag(editCmd, utils.FlagOutput, &editOutput, "", "Output format: \"json\" prints the full server response (default: summary)") editCmd.Flags().BoolVar(&editInsecure, "insecure", false, "Skip TLS certificate verification") _ = editCmd.MarkFlagRequired(utils.FlagFile) - _ = editCmd.MarkFlagRequired(utils.FlagOrgID) _ = editCmd.MarkFlagRequired(utils.FlagProjectID) } func runEditCommand() error { - orgID := strings.TrimSpace(editOrgID) - if orgID == "" { - return fmt.Errorf("organization ID is required") - } projectID := strings.TrimSpace(editProjectID) if projectID == "" { return fmt.Errorf("project ID is required") @@ -115,17 +108,16 @@ func runEditCommand() error { return fmt.Errorf("failed to load config: %w", err) } - aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, editName, editPlatform) + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, editName, editPlatform) if err != nil { return err } client := aiworkspace.NewClientWithOptions(aiWorkspace, editInsecure) - resp, err := client.PutJSON(aiworkspace.MCPProxyResourcePath(orgID, proxyID), body) + resp, err := client.PutJSON(aiworkspace.MCPProxyByIDPath(proxyID), body) if err != nil { return aiworkspace.WrapRequestError("update mcp proxy", err, editInsecure) } - return aiworkspace.PrintArtifactResult(resp, editOutput, proxyID, - fmt.Sprintf("MCP proxy updated on ai-workspace %s (platform: %s)", aiWorkspace.Name, resolvedPlatform)) + return aiworkspace.PrintApplyResult(resp, editOutput, "Mcp", "updated", proxyID, projectID) } diff --git a/cli/src/cmd/aiws/mcpproxy/push.go b/cli/src/cmd/aiws/mcpproxy/push.go index a1659bcd7b..410a330c03 100644 --- a/cli/src/cmd/aiws/mcpproxy/push.go +++ b/cli/src/cmd/aiws/mcpproxy/push.go @@ -33,15 +33,14 @@ import ( const ( PushCmdLiteral = "push" PushCmdExample = `# Push an MCP proxy artifact using the active AI workspace -ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --org --project-id +ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --project-id # Push using a specific AI workspace -ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --org --project-id --display-name my-workspace --platform eu` +ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --project-id --display-name my-workspace --platform eu` ) var ( pushFilePath string - pushOrgID string pushProjectID string pushName string pushPlatform string @@ -64,22 +63,16 @@ var pushCmd = &cobra.Command{ func init() { utils.AddStringFlag(pushCmd, utils.FlagFile, &pushFilePath, "", "Path to the MCP proxy payload JSON file (required)") - utils.AddStringFlag(pushCmd, utils.FlagOrgID, &pushOrgID, "", "Organization ID (required)") utils.AddStringFlag(pushCmd, utils.FlagProjectID, &pushProjectID, "", "Project ID to set on the payload (required)") utils.AddStringFlag(pushCmd, utils.FlagName, &pushName, "", "AI workspace display name") utils.AddStringFlag(pushCmd, utils.FlagPlatform, &pushPlatform, "", "Platform name") utils.AddStringFlag(pushCmd, utils.FlagOutput, &pushOutput, "", "Output format: \"json\" prints the full server response (default: summary)") pushCmd.Flags().BoolVar(&pushInsecure, "insecure", false, "Skip TLS certificate verification") _ = pushCmd.MarkFlagRequired(utils.FlagFile) - _ = pushCmd.MarkFlagRequired(utils.FlagOrgID) _ = pushCmd.MarkFlagRequired(utils.FlagProjectID) } func runPushCommand() error { - orgID := strings.TrimSpace(pushOrgID) - if orgID == "" { - return fmt.Errorf("organization ID is required") - } projectID := strings.TrimSpace(pushProjectID) if projectID == "" { return fmt.Errorf("project ID is required") @@ -115,17 +108,16 @@ func runPushCommand() error { return fmt.Errorf("failed to load config: %w", err) } - aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, pushName, pushPlatform) + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, pushName, pushPlatform) if err != nil { return err } client := aiworkspace.NewClientWithOptions(aiWorkspace, pushInsecure) - resp, err := client.PostJSON(aiworkspace.MCPProxyPath(orgID), body) + resp, err := client.PostJSON(aiworkspace.MCPProxyPath(), body) if err != nil { return aiworkspace.WrapRequestError("push mcp proxy", err, pushInsecure) } - return aiworkspace.PrintArtifactResult(resp, pushOutput, proxyID, - fmt.Sprintf("MCP proxy pushed to ai-workspace %s (platform: %s)", aiWorkspace.Name, resolvedPlatform)) + return aiworkspace.PrintApplyResult(resp, pushOutput, "Mcp", "applied", proxyID, projectID) } diff --git a/cli/src/cmd/aiws/mcpproxy/root.go b/cli/src/cmd/aiws/mcpproxy/root.go index e9fe1d64ba..d99d426bab 100644 --- a/cli/src/cmd/aiws/mcpproxy/root.go +++ b/cli/src/cmd/aiws/mcpproxy/root.go @@ -25,7 +25,7 @@ import ( const ( MCPProxyCmdLiteral = "mcp-proxy" MCPProxyCmdExample = `# Push an MCP proxy artifact to the AI workspace -ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --org --project-id ` +ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --project-id ` ) // MCPProxyCmd is the parent command for MCP proxy operations. diff --git a/cli/src/cmd/platform/remove.go b/cli/src/cmd/platform/remove.go new file mode 100644 index 0000000000..e79e5ea95b --- /dev/null +++ b/cli/src/cmd/platform/remove.go @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package platform + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + RemoveCmdLiteral = "remove" + RemoveCmdExample = `# Remove a platform +ap platform remove --display-name dev` +) + +var removeName string + +var removeCmd = &cobra.Command{ + Use: RemoveCmdLiteral, + Short: "Remove a platform", + Long: "Remove a platform and all of its connections (gateways, devportals, AI workspaces) from the CLI configuration.", + Example: RemoveCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runRemoveCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(removeCmd, utils.FlagName, &removeName, "", "Name of the platform to remove (required)") + _ = removeCmd.MarkFlagRequired(utils.FlagName) +} + +func runRemoveCommand() error { + name := strings.TrimSpace(removeName) + if name == "" { + return fmt.Errorf("platform name is required") + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if err := cfg.RemovePlatform(name); err != nil { + return err + } + + if err := config.SaveConfig(cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("Platform %s removed.\n", name) + return nil +} diff --git a/cli/src/cmd/platform/root.go b/cli/src/cmd/platform/root.go index 21f1129ccc..0be35be40f 100644 --- a/cli/src/cmd/platform/root.go +++ b/cli/src/cmd/platform/root.go @@ -32,7 +32,10 @@ ap platform use --display-name dev ap platform current # List all platforms -ap platform list` +ap platform list + +# Remove a platform +ap platform remove --display-name dev` ) var PlatformCmd = &cobra.Command{ @@ -50,4 +53,5 @@ func init() { PlatformCmd.AddCommand(useCmd) PlatformCmd.AddCommand(currentCmd) PlatformCmd.AddCommand(listCmd) + PlatformCmd.AddCommand(removeCmd) } diff --git a/cli/src/cmd/project/init.go b/cli/src/cmd/project/init.go index 5ff57f4df0..02113b0fc6 100644 --- a/cli/src/cmd/project/init.go +++ b/cli/src/cmd/project/init.go @@ -71,7 +71,7 @@ func runInitCommand() error { } } if strings.TrimSpace(projectType) == "" { - projectType, err = utils.PromptInput(fmt.Sprintf("Enter artifact type (%s): ", strings.Join(project.SupportedArtifactTypes(), ", "))) + projectType, err = utils.PromptSelect("Select artifact type:", project.SupportedArtifactTypes()) if err != nil { return fmt.Errorf("Failed to read artifact type: %w", err) } diff --git a/cli/src/internal/aiworkspace/helpers.go b/cli/src/internal/aiworkspace/helpers.go index e629393677..3e49dbed02 100644 --- a/cli/src/internal/aiworkspace/helpers.go +++ b/cli/src/internal/aiworkspace/helpers.go @@ -60,40 +60,25 @@ func ResolveAIWorkspace(cfg *config.Config, selectedName, selectedPlatform strin return aiWorkspace, resolvedPlatform, nil } -// ProviderPath builds the llm-providers resource path with the organizationId -// query parameter. -func ProviderPath(orgID string) string { - return withOrg(utils.AIWorkspaceLLMProvidersPath, orgID) +// ProviderPath returns the llm-providers collection path used to create a +// provider. The organization is derived from the auth token, so no +// organizationId query parameter is added. +func ProviderPath() string { + return utils.AIWorkspaceLLMProvidersPath } -// ProxyPath builds the llm-proxies resource path with the organizationId query -// parameter. -func ProxyPath(orgID string) string { - return withOrg(utils.AIWorkspaceLLMProxiesPath, orgID) +// ProxyPath returns the llm-proxies collection path used to create a proxy. The +// organization is derived from the auth token, so no organizationId query +// parameter is added. +func ProxyPath() string { + return utils.AIWorkspaceLLMProxiesPath } -// MCPProxyPath builds the mcp-proxies resource path with the organizationId -// query parameter. -func MCPProxyPath(orgID string) string { - return withOrg(utils.AIWorkspaceMCPProxiesPath, orgID) -} - -// ProviderResourcePath builds the llm-providers/{id} resource path with the -// organizationId query parameter. -func ProviderResourcePath(orgID, id string) string { - return withOrg(utils.AIWorkspaceLLMProvidersPath+"/"+url.PathEscape(id), orgID) -} - -// ProxyResourcePath builds the llm-proxies/{id} resource path with the -// organizationId query parameter. -func ProxyResourcePath(orgID, id string) string { - return withOrg(utils.AIWorkspaceLLMProxiesPath+"/"+url.PathEscape(id), orgID) -} - -// MCPProxyResourcePath builds the mcp-proxies/{id} resource path with the -// organizationId query parameter. -func MCPProxyResourcePath(orgID, id string) string { - return withOrg(utils.AIWorkspaceMCPProxiesPath+"/"+url.PathEscape(id), orgID) +// MCPProxyPath returns the mcp-proxies collection path used to create an MCP +// proxy. The organization is derived from the auth token, so no organizationId +// query parameter is added. +func MCPProxyPath() string { + return utils.AIWorkspaceMCPProxiesPath } // ProviderByIDPath builds the llm-providers/{id} path with only the id path @@ -114,10 +99,6 @@ func MCPProxyByIDPath(id string) string { return utils.AIWorkspaceMCPProxiesPath + "/" + url.PathEscape(id) } -func withOrg(path, orgID string) string { - return fmt.Sprintf("%s?organizationId=%s", path, url.QueryEscape(orgID)) -} - func withProject(path, projectID string) string { return fmt.Sprintf("%s?projectId=%s", path, url.QueryEscape(projectID)) } @@ -128,28 +109,33 @@ type ListQuery struct { Offset string } -func withListParams(basePath, orgID string, q ListQuery) string { - return appendPagination(withOrg(basePath, orgID), q) -} - func withProjectListParams(basePath, projectID string, q ListQuery) string { return appendPagination(withProject(basePath, projectID), q) } func appendPagination(path string, q ListQuery) string { - if v := strings.TrimSpace(q.Limit); v != "" { - path += "&limit=" + url.QueryEscape(v) + // Use "?" for the first query parameter when the path has none yet + // (provider list), otherwise "&" (proxy/mcp list already carry ?projectId=). + sep := "?" + if strings.Contains(path, "?") { + sep = "&" } - if v := strings.TrimSpace(q.Offset); v != "" { - path += "&offset=" + url.QueryEscape(v) + addParam := func(key, value string) { + if v := strings.TrimSpace(value); v != "" { + path += sep + key + "=" + url.QueryEscape(v) + sep = "&" + } } + addParam("limit", q.Limit) + addParam("offset", q.Offset) return path } -// ProviderListPath builds the llm-providers list path with the organizationId -// query parameter and optional pagination. -func ProviderListPath(orgID string, q ListQuery) string { - return withListParams(utils.AIWorkspaceLLMProvidersPath, orgID, q) +// ProviderListPath builds the llm-providers list path with optional pagination. +// The organization is derived from the auth token, so no organizationId query +// parameter is added. +func ProviderListPath(q ListQuery) string { + return appendPagination(utils.AIWorkspaceLLMProvidersPath, q) } // ProxyListPath builds the llm-proxies list path with the projectId query @@ -218,16 +204,30 @@ func OutputJSON(format string) bool { return strings.EqualFold(strings.TrimSpace(format), "json") } -// PrintArtifactResult prints the result of a create/edit operation. By default -// it prints a concise summary line that always surfaces the artifact id (the -// value other commands need for --id), so the server-returned artifact body is -// drained and discarded rather than cluttering the terminal. When the output -// format is "json" the full response body is pretty-printed instead, so the -// command stays scriptable (e.g. `... -o json | jq`). +// PrintApplyResult prints the result of a create/edit (push/edit) operation in +// the same structured, key-value form as `ap gateway apply`, e.g.: +// +// Status: success +// Message: LlmProvider applied successfully +// ID: wso2-claude-provider +// Organization: 019f2324-... +// Project: 019f2324-... +// Created At: 2026-07-03T06:31:22Z +// Updated At: 2026-07-03T06:31:22Z +// State: deployed +// +// kind is the artifact kind ("LlmProvider", "LlmProxy", "Mcp"); action is the +// past-tense verb ("applied" for create, "updated" for edit). Organization / +// Project / Created At / Updated At / State are printed only when known. +// Organization comes from the response (the CLI derives it from the auth token, +// so it only shows when the server echoes organizationId); Project comes from +// the response or, failing that, the locally supplied fallbackProject +// (--project-id). When the output format is "json" the full response body is +// pretty-printed instead, so the command stays scriptable (e.g. `... -o json | jq`). // -// fallbackID is the id known locally (from the pushed payload); it is used when -// the server response does not echo an id. -func PrintArtifactResult(resp *http.Response, outputFormat, fallbackID, summary string) error { +// fallbackID / fallbackProject are the values known locally (from the pushed +// payload / --project-id); they are used when the server response omits them. +func PrintApplyResult(resp *http.Response, outputFormat, kind, action, fallbackID, fallbackProject string) error { if OutputJSON(outputFormat) { return PrintJSONResponse(resp) } @@ -235,39 +235,48 @@ func PrintArtifactResult(resp *http.Response, outputFormat, fallbackID, summary defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) - id, details := summarizeArtifact(body) - if strings.TrimSpace(id) == "" { + var m map[string]interface{} + _ = json.Unmarshal(body, &m) + + id := stringField(m, "id") + if id == "" { id = strings.TrimSpace(fallbackID) } + project := stringField(m, "projectId") + if project == "" { + project = strings.TrimSpace(fallbackProject) + } - var suffix []string + fmt.Println("Status: success") + fmt.Printf("Message: %s %s successfully\n", kind, action) if id != "" { - suffix = append(suffix, "id: "+id) + fmt.Printf("ID: %s\n", id) } - suffix = append(suffix, details...) - if len(suffix) > 0 { - summary += " (" + strings.Join(suffix, ", ") + ")" + if v := stringField(m, "organizationId"); v != "" { + fmt.Printf("Organization: %s\n", v) } - - fmt.Println(summary) - return nil -} - -// summarizeArtifact extracts the server-assigned id and a few additional fields -// (version, status) worth surfacing in the summary line. -func summarizeArtifact(body []byte) (id string, details []string) { - var m map[string]interface{} - if err := json.Unmarshal(body, &m); err != nil { - return "", nil + if project != "" { + fmt.Printf("Project: %s\n", project) + } + if v := stringField(m, "createdAt"); v != "" { + fmt.Printf("Created At: %s\n", v) } - id, _ = m["id"].(string) - if v, ok := m["version"].(string); ok && strings.TrimSpace(v) != "" { - details = append(details, "version: "+v) + if v := stringField(m, "updatedAt"); v != "" { + fmt.Printf("Updated At: %s\n", v) } - if s, ok := m["status"].(string); ok && strings.TrimSpace(s) != "" { - details = append(details, "status: "+s) + // The LLM provider/proxy and MCP proxy responses carry the deployment state + // in the top-level "status" field (pending/deployed/failed). + if v := stringField(m, "status"); v != "" { + fmt.Printf("State: %s\n", v) } - return strings.TrimSpace(id), details + return nil +} + +// stringField returns the trimmed string value of m[key], or "" when absent or +// not a string. +func stringField(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return strings.TrimSpace(v) } // PrintJSONResponse prints an HTTP response as pretty JSON when possible and diff --git a/cli/src/internal/aiworkspace/paths_test.go b/cli/src/internal/aiworkspace/paths_test.go new file mode 100644 index 0000000000..3e87dbd7cc --- /dev/null +++ b/cli/src/internal/aiworkspace/paths_test.go @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiworkspace + +import ( + "testing" + + "github.com/wso2/api-platform/cli/utils" +) + +func TestProviderListPath_NoOrgWithPagination(t *testing.T) { + base := utils.AIWorkspaceLLMProvidersPath + + if got := ProviderListPath(ListQuery{}); got != base { + t.Fatalf("no pagination: got %q, want %q", got, base) + } + if got := ProviderListPath(ListQuery{Limit: "50"}); got != base+"?limit=50" { + t.Fatalf("limit only: got %q", got) + } + if got := ProviderListPath(ListQuery{Limit: "50", Offset: "10"}); got != base+"?limit=50&offset=10" { + t.Fatalf("limit+offset: got %q", got) + } + if got := ProviderListPath(ListQuery{Offset: "10"}); got != base+"?offset=10" { + t.Fatalf("offset only: got %q", got) + } +} + +func TestProviderByIDPath_NoQuery(t *testing.T) { + want := utils.AIWorkspaceLLMProvidersPath + "/wso2-claude" + if got := ProviderByIDPath("wso2-claude"); got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestProxyListPath_KeepsProjectIdThenAmpersand(t *testing.T) { + // Project-scoped list still uses ?projectId=... and & for pagination. + got := ProxyListPath("proj-1", ListQuery{Limit: "5"}) + want := utils.AIWorkspaceLLMProxiesPath + "?projectId=proj-1&limit=5" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} diff --git a/cli/src/internal/aiworkspace/print_test.go b/cli/src/internal/aiworkspace/print_test.go new file mode 100644 index 0000000000..9460f9c8f4 --- /dev/null +++ b/cli/src/internal/aiworkspace/print_test.go @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiworkspace + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/wso2/api-platform/cli/test/testutil" +) + +func responseWith(body string) *http.Response { + return &http.Response{ + StatusCode: http.StatusCreated, + Body: io.NopCloser(strings.NewReader(body)), + Header: http.Header{}, + } +} + +func TestPrintApplyResult_StructuredOutput(t *testing.T) { + body := `{"id":"claude-proxy2","organizationId":"org-1","projectId":"proj-1","createdAt":"2026-07-03T06:31:22Z","updatedAt":"2026-07-03T06:31:22Z","status":"deployed"}` + resp := responseWith(body) + + out := testutil.CaptureStdout(t, func() { + if err := PrintApplyResult(resp, "", "LlmProxy", "applied", "fallback-id", "fallback-proj"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + for _, want := range []string{ + "Status: success", + "Message: LlmProxy applied successfully", + "ID: claude-proxy2", + "Organization: org-1", + "Project: proj-1", + "Created At: 2026-07-03T06:31:22Z", + "Updated At: 2026-07-03T06:31:22Z", + "State: deployed", + } { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q\n---\n%s", want, out) + } + } +} + +func TestPrintApplyResult_FallbackProjectAndNoOrgWhenAbsent(t *testing.T) { + // Response omits organizationId and projectId: Project falls back to the + // locally supplied --project-id, Organization is dropped entirely. + resp := responseWith(`{"id":"p"}`) + + out := testutil.CaptureStdout(t, func() { + if err := PrintApplyResult(resp, "", "LlmProxy", "updated", "fallback-id", "local-proj"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if !strings.Contains(out, "Project: local-proj") { + t.Fatalf("expected fallback project, got:\n%s", out) + } + if strings.Contains(out, "Organization:") { + t.Fatalf("expected no Organization line when absent, got:\n%s", out) + } +} + +func TestPrintApplyResult_UsesFallbackIDAndOmitsMissingFields(t *testing.T) { + // Response without id/timestamps/status and no project fallback: id falls + // back, optional lines dropped. + resp := responseWith(`{}`) + + out := testutil.CaptureStdout(t, func() { + if err := PrintApplyResult(resp, "", "LlmProvider", "updated", "fallback-id", ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if !strings.Contains(out, "Message: LlmProvider updated successfully") { + t.Fatalf("expected updated message, got:\n%s", out) + } + if !strings.Contains(out, "ID: fallback-id") { + t.Fatalf("expected fallback id, got:\n%s", out) + } + if strings.Contains(out, "Created At:") || strings.Contains(out, "State:") || + strings.Contains(out, "Organization:") || strings.Contains(out, "Project:") { + t.Fatalf("expected no optional lines, got:\n%s", out) + } +} + +func TestPrintApplyResult_JSONOutputPassthrough(t *testing.T) { + resp := responseWith(`{"id":"x","status":"deployed"}`) + + out := testutil.CaptureStdout(t, func() { + if err := PrintApplyResult(resp, "json", "Mcp", "applied", "x", ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + // json output mode prints the raw response, not the structured summary. + if strings.Contains(out, "Status: success") { + t.Fatalf("expected raw json output, got structured summary:\n%s", out) + } + if !strings.Contains(out, "\"status\": \"deployed\"") { + t.Fatalf("expected pretty-printed json, got:\n%s", out) + } +} diff --git a/cli/src/internal/config/config.go b/cli/src/internal/config/config.go index 984d4da761..6d10eedc3d 100644 --- a/cli/src/internal/config/config.go +++ b/cli/src/internal/config/config.go @@ -553,3 +553,18 @@ func (c *Config) RemoveAIWorkspaceFromPlatform(platformName, name string) error func (c *Config) RemoveAIWorkspace(name string) error { return c.RemoveAIWorkspaceFromPlatform("", name) } + +// RemovePlatform deletes a platform and all of its connections from the config. +// If the removed platform was the current one, the current selection is reset so +// it falls back to the default platform. +func (c *Config) RemovePlatform(platformName string) error { + name := normalizePlatformName(platformName) + if c.Platforms == nil || c.Platforms[name] == nil { + return fmt.Errorf("platform '%s' not found", name) + } + delete(c.Platforms, name) + if normalizePlatformName(c.CurrentPlatform) == name { + c.CurrentPlatform = "" + } + return nil +} diff --git a/cli/src/internal/config/remove_platform_test.go b/cli/src/internal/config/remove_platform_test.go new file mode 100644 index 0000000000..9e7b48ac68 --- /dev/null +++ b/cli/src/internal/config/remove_platform_test.go @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "strings" + "testing" +) + +func TestRemovePlatform_DeletesPlatform(t *testing.T) { + cfg := &Config{ + CurrentPlatform: "eu", + Platforms: map[string]*Platform{ + "eu": {}, + "default": {}, + }, + } + + if err := cfg.RemovePlatform("eu"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := cfg.Platforms["eu"]; ok { + t.Fatalf("expected platform 'eu' to be removed") + } + // Removing the current platform resets the selection to the default. + if got := cfg.GetCurrentPlatform(); got != DefaultPlatform { + t.Fatalf("expected current platform to reset to %q, got %q", DefaultPlatform, got) + } +} + +func TestRemovePlatform_KeepsCurrentWhenOtherRemoved(t *testing.T) { + cfg := &Config{ + CurrentPlatform: "eu", + Platforms: map[string]*Platform{ + "eu": {}, + "us": {}, + }, + } + + if err := cfg.RemovePlatform("us"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.GetCurrentPlatform() != "eu" { + t.Fatalf("expected current platform to stay 'eu', got %q", cfg.GetCurrentPlatform()) + } +} + +func TestRemovePlatform_NotFound(t *testing.T) { + cfg := &Config{Platforms: map[string]*Platform{"default": {}}} + + err := cfg.RemovePlatform("missing") + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected not-found error, got %v", err) + } +} diff --git a/cli/src/internal/project/artifact.go b/cli/src/internal/project/artifact.go index 5a0e805c2e..0dfc6df7c1 100644 --- a/cli/src/internal/project/artifact.go +++ b/cli/src/internal/project/artifact.go @@ -40,7 +40,6 @@ func SupportedArtifactTypes() []string { utils.TypeREST, utils.TypeLLMProxy, utils.TypeLLMProvider, - utils.TypeLLMProviderTemplate, utils.TypeMCPProxy, } } @@ -60,7 +59,7 @@ func IsValidArtifactType(artifactType string) bool { // management plane (REST APIs). func IsAIWorkspaceType(artifactType string) bool { switch artifactType { - case utils.TypeLLMProxy, utils.TypeLLMProvider, utils.TypeLLMProviderTemplate, utils.TypeMCPProxy: + case utils.TypeLLMProxy, utils.TypeLLMProvider, utils.TypeMCPProxy: return true default: return false @@ -76,8 +75,6 @@ func ArtifactKind(artifactType string) string { return "LlmProxy" case utils.TypeLLMProvider: return "LlmProvider" - case utils.TypeLLMProviderTemplate: - return "LlmProviderTemplate" case utils.TypeMCPProxy: return "Mcp" default: diff --git a/cli/src/internal/project/scaffold.go b/cli/src/internal/project/scaffold.go index 77f77c53fb..b4a18dfa41 100644 --- a/cli/src/internal/project/scaffold.go +++ b/cli/src/internal/project/scaffold.go @@ -107,7 +107,7 @@ const portalConfigTemplate = ` # filePaths: # paths relative to portal root # metadata: ./artifact.yaml # runtime: ./runtime.yaml -# definition: ./definition.yaml # only folded into the payload with --use-spec +# definition: ./definition.yaml # OpenAPI spec folded into the payload # Dev portal configurations # devportals: diff --git a/cli/src/utils/constants.go b/cli/src/utils/constants.go index a097314037..41fbfe8da4 100644 --- a/cli/src/utils/constants.go +++ b/cli/src/utils/constants.go @@ -88,9 +88,9 @@ const ( AIWorkspaceAPIHeader = "x-wso2-api-key" // AI Workspace REST API Endpoints - AIWorkspaceLLMProvidersPath = "/api-proxy/api/v0.9/llm-providers" - AIWorkspaceLLMProxiesPath = "/api-proxy/api/v0.9/llm-proxies" - AIWorkspaceMCPProxiesPath = "/api-proxy/api/v0.9/mcp-proxies" + AIWorkspaceLLMProvidersPath = "/api/v0.9/llm-providers" + AIWorkspaceLLMProxiesPath = "/api/v0.9/llm-proxies" + AIWorkspaceMCPProxiesPath = "/api/v0.9/mcp-proxies" // Image Build Configuration GatewayVerifyChecksumOnBuild = true @@ -101,12 +101,11 @@ const ( MaxTotalUncompressed = 100 * 1024 * 1024 // Maximum total uncompressed size allowed for the archive (100 MB). // Artifact Types - TypeREST = "rest" - TypeSOAP = "soap" - TypeLLMProxy = "llm-proxy" - TypeLLMProvider = "llm-provider" - TypeLLMProviderTemplate = "llm-provider-template" - TypeMCPProxy = "mcp-proxy" + TypeREST = "rest" + TypeSOAP = "soap" + TypeLLMProxy = "llm-proxy" + TypeLLMProvider = "llm-provider" + TypeMCPProxy = "mcp-proxy" ) // PolicyHub REST API defaults and paths diff --git a/cli/src/utils/flags.go b/cli/src/utils/flags.go index 0b52fda82e..f793c0ca25 100644 --- a/cli/src/utils/flags.go +++ b/cli/src/utils/flags.go @@ -83,7 +83,6 @@ const ( FlagBillingSubscriptionID = "billing-subscription-id" FlagReferenceID = "reference-id" FlagGatewayType = "gateway-type" - FlagUseSpec = "use-spec" FlagProjectID = "project-id" ) diff --git a/cli/src/utils/input.go b/cli/src/utils/input.go index 2d267c31dd..1a5f518814 100644 --- a/cli/src/utils/input.go +++ b/cli/src/utils/input.go @@ -22,23 +22,65 @@ import ( "bufio" "fmt" "os" + "strconv" "strings" "syscall" "golang.org/x/term" ) +// stdinReader is a shared buffered reader over os.Stdin. Prompts must share one +// reader: a bufio.Reader reads ahead, so a fresh reader per prompt can discard +// input buffered by a previous prompt (breaking successive prompts on piped +// input). +var stdinReader = bufio.NewReader(os.Stdin) + // PromptInput prompts the user for input and returns the trimmed response func PromptInput(prompt string) (string, error) { fmt.Print(prompt) - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') + input, err := stdinReader.ReadString('\n') if err != nil { return "", fmt.Errorf("failed to read input: %w", err) } return strings.TrimSpace(input), nil } +// PromptSelect presents a numbered list of options and returns the option the +// user selects. The user may enter either the number (1-based) or the option +// value itself (case-insensitive). It re-prompts on invalid input and returns +// an error only when input cannot be read (e.g. EOF). +func PromptSelect(prompt string, options []string) (string, error) { + fmt.Println(prompt) + for i, option := range options { + fmt.Printf(" %d) %s\n", i+1, option) + } + + for { + fmt.Printf("Enter number [1-%d]: ", len(options)) + input, err := stdinReader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read input: %w", err) + } + input = strings.TrimSpace(input) + + if n, convErr := strconv.Atoi(input); convErr == nil { + if n >= 1 && n <= len(options) { + return options[n-1], nil + } + fmt.Printf("Invalid selection %q; enter a number between 1 and %d.\n", input, len(options)) + continue + } + + // Fall back to matching the option value itself. + for _, option := range options { + if strings.EqualFold(input, option) { + return option, nil + } + } + fmt.Printf("Invalid selection %q; enter a number between 1 and %d.\n", input, len(options)) + } +} + // PromptPassword prompts the user for a password with masked input func PromptPassword(prompt string) (string, error) { fmt.Print(prompt) diff --git a/docs/cli/ai-workspace/README.md b/docs/cli/ai-workspace/README.md index 71faecc7f4..aeebb43179 100644 --- a/docs/cli/ai-workspace/README.md +++ b/docs/cli/ai-workspace/README.md @@ -141,7 +141,7 @@ Generates an **LLM proxy**, **LLM provider**, or **MCP proxy** creation payload Any other kind is rejected. ```shell -ap ai-ws build [-f ] [-o ] [--use-spec] +ap ai-ws build [-f ] [-o ] ``` Examples: @@ -158,9 +158,6 @@ ap ai-ws build -o build/ # Write the payload to a specific file ap ai-ws build -o build/openai.json - -# Fold the OpenAPI spec (definition.yaml) into the payload -ap ai-ws build --use-spec ``` ### What it reads @@ -174,7 +171,7 @@ ai-workspaces: filePaths: # paths relative to portalRoot metadata: ./metadata.yaml runtime: ./runtime.yaml - definition: ./definition.yaml # required for LlmProvider; opt-in for LlmProxy via --use-spec + definition: ./definition.yaml # OpenAPI spec, required for all kinds ``` For each configured entry, the build: @@ -183,7 +180,7 @@ For each configured entry, the build: - Requires `metadata.yaml` and `runtime.yaml` to exist. - Requires the `kind` declared in `metadata.yaml` and `runtime.yaml` to match; otherwise the build fails with a kind-mismatch error. - Requires `metadata.name` to match between `metadata.yaml` and `runtime.yaml`; otherwise the build fails with a name-mismatch error. -- Handles `definition.yaml` (the OpenAPI spec) by kind — see [The OpenAPI spec](#the-openapi-spec---use-spec) below. +- Requires `definition.yaml` (the OpenAPI spec) for every kind — see [The OpenAPI spec](#the-openapi-spec) below. - If no `ai-workspaces` section exists, a single `default` entry (`portalRoot: .`) is created in the project config and used. All resolved paths are constrained to the project directory; a path that escapes the project root fails the build for that entry. @@ -217,15 +214,18 @@ One JSON file per configured AI-Workspace entry, written to the build output. | Payload field | Source | | --- | --- | | `id` | `metadata.yaml` → `metadata.name` | -| `name` | `metadata.yaml` → `metadata.name` | +| `displayName` | `metadata.yaml` → `spec.displayName` | | `version` | `metadata.yaml` → `spec.version` | | `context` | `runtime.yaml` → `spec.context` | -| `provider` (`id`, `auth.{type,header,value}`) | `runtime.yaml` → `spec.provider` | -| `policies[]` (`name`, `version`, `paths[].{path,methods,params}`) | `runtime.yaml` → `spec.policies` | -| `openapi` | content of `definition.yaml` with `--use-spec`, otherwise empty | +| `description` | `runtime.yaml` → `spec.description` (defaults to `"No description provided for this proxy."` when absent) | +| `provider` (`id`, `auth.{type,header}`) | `runtime.yaml` → `spec.provider` (the auth secret `value` is **not** copied — the provider owns it) | +| `security` (`enabled`, `apiKey.{enabled,in,key}`) | the `api-key-auth` policy in `runtime.yaml` → `spec.globalPolicies` | +| `globalPolicies[]` (`name`, `version`, `params`) | every other `runtime.yaml` → `spec.globalPolicies` entry; `params` is copied verbatim (policy-specific, no fixed schema) | +| `operationPolicies[]` (`name`, `version`, `paths[].{path,methods,params}`) | `runtime.yaml` → `spec.operationPolicies`; each path's `params` is copied verbatim | +| `readOnly` | always `false` | +| `openapi` | content of `definition.yaml` (**required**) | | `associatedGateways[]` (`id`, `configurations`) | `metadata.yaml` → `associatedGateways` (top-level) (omitted when absent) | -| `vhost` | always empty (filled in at publish time) | -| `projectId` | intentionally omitted | +| `projectId` | intentionally omitted (injected by `push`/`edit` via `--project-id`) | #### `LlmProvider` @@ -236,6 +236,7 @@ One JSON file per configured AI-Workspace entry, written to the build output. | `version` | `metadata.yaml` → `spec.version` | | `context` | `runtime.yaml` → `spec.context` | | `template` | `runtime.yaml` → `spec.template` | +| `modelProviders[]` (`id`, `displayName`, `models[].{id,displayName}`) | derived from `spec.template` (see below); omitted for an unknown template | | `upstream` (`main.{url,auth}`) | `runtime.yaml` → `spec.upstream` | | `accessControl` (`mode`, `exceptions[]`) | `runtime.yaml` → `spec.accessControl` | | `security` (`apiKey.{key,in}`) | the `api-key-auth` policy in `runtime.yaml` → `spec.policies` | @@ -256,7 +257,16 @@ Each dimension is placed under `consumerLevel` when the policy params carry `con A limit whose path is `/*` is applied as a `global` limit for its scope; a limit on a specific path is applied `resourceWise`, keyed by that path (with any `/*` limits in the same scope folded into the resourceWise `default`). -> Not yet emitted: `modelProviders` (no source defined in the project artifacts yet). +**modelProviders mapping.** When `spec.template` matches a known template, the build emits a single `modelProviders` entry keyed by the template name (`id` = `displayName` = the template), carrying that template's models (each model's `id` and `displayName` are the model identifier). Unknown templates emit no `modelProviders`. Supported templates and their models: + +| Template | Models | +| --- | --- | +| `meta` | `us.meta.llama3-3-70b-instruct-v1:0`, `us.meta.llama4-maverick-17b-instruct-v1:0` | +| `openai` | `gpt-4o-mini`, `gpt-4.1-mini`, `o4-mini` | +| `anthropic` | `claude-3.5-sonnet`, `claude-3-opus` | +| `google-vertex` | `gemini-1.5-pro`, `gemini-1.5-flash` | +| `aws-bedrock` | `amazon.titan-text-premier`, `anthropic.claude-v2` | +| `mistralai` | `mistral-large-latest`, `mistral-small-latest`, `open-mixtral-8x22b` | #### `Mcp` @@ -275,13 +285,13 @@ A limit whose path is `/*` is applied as a `global` limit for its scope; a limit `definition.yaml` for an MCP proxy holds `prompts`, `resources`, and `tools`. `prompts` and `tools` are passed through unchanged; `resources` are trimmed to `uri`, `name`, and `mimeType` (any inline `text`/`blob` content is dropped). `projectId` is omitted and injected at publish time. -### The OpenAPI spec (`--use-spec`) +### The OpenAPI spec -The `definition.yaml` is handled by kind: +`definition.yaml` is **required for every kind** — the build errors if it is missing: -- **`LlmProvider`** — **required**: `definition.yaml` must exist (the build errors otherwise) and is always folded into `openapi`. `--use-spec` is not needed. -- **`Mcp`** — **required**: `definition.yaml` must exist; its `prompts`/`resources`/`tools` populate `capabilities`. `--use-spec` is not needed. -- **`LlmProxy`** — **opt-in**: `openapi` is empty by default even when `definition.yaml` exists; pass `--use-spec` to fold it in. A missing `definition.yaml` with `--use-spec` leaves the field empty (no error). +- **`LlmProvider`** — folded into `openapi`. +- **`LlmProxy`** — folded into `openapi`. +- **`Mcp`** — its `prompts`/`resources`/`tools` populate `capabilities`. ### Output location and artifact names (`-o`) @@ -303,25 +313,27 @@ These commands retrieve artifacts from the AI workspace resolved from the CLI co The scoping query parameter differs by resource: -- **LLM providers** are scoped by `organizationId` (`--org`). +- **LLM providers** need no scoping parameter — the organization is derived from the auth token (`GET /llm-providers`, `GET /llm-providers/{id}`). - **LLM/MCP proxies** are scoped by `projectId` (`--project-id`) when listing; fetching a single proxy by `--id` takes only the id path parameter (no org/project query). ### `ap ai-ws llm-provider list` -Lists all LLM providers in an organization (`GET /llm-providers?organizationId={org}`, operationId `listLLMProviders`). +Lists all LLM providers (`GET /llm-providers`, operationId `listLLMProviders`). The organization comes from the auth token, so no `--org` is needed. ```shell -ap ai-ws llm-provider list --org [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] +ap ai-ws llm-provider list [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] ``` ### `ap ai-ws llm-provider get` +The organization comes from the auth token, so no `--org` is needed. + ```shell -# List all LLM providers -ap ai-ws llm-provider get --org [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] +# List all LLM providers (GET /llm-providers) +ap ai-ws llm-provider get [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] -# Get a single LLM provider -ap ai-ws llm-provider get --org --id +# Get a single LLM provider (GET /llm-providers/{id}) +ap ai-ws llm-provider get --id ``` ### `ap ai-ws llm-proxy get` @@ -346,8 +358,8 @@ ap ai-ws mcp-proxy get --id Notes: -- `llm-provider list` and `llm-provider get` both list providers when no `--id` is given; `list` is the dedicated list-all command (`--org` required), while `get` additionally fetches a single provider with `--id`. -- For `llm-provider get`, `--org` is required. For `llm-proxy`/`mcp-proxy get`, `--project-id` is required only when listing; fetching a single proxy needs just `--id`. +- `llm-provider list` and `llm-provider get` both list providers when no `--id` is given; `list` is the dedicated list-all command, while `get` additionally fetches a single provider with `--id`. Neither needs `--org` (the organization is derived from the auth token). +- For `llm-proxy`/`mcp-proxy get`, `--project-id` is required only when listing; fetching a single proxy needs just `--id`. - `--limit` and `--offset` apply only when listing. - `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. @@ -380,90 +392,90 @@ Notes: ## Push Commands -These commands push a payload JSON (produced by `ap ai-ws build`) to the AI workspace server resolved from the CLI config (`--display-name`/`--platform`, or the active AI workspace). The resource `id` in the request URL is taken from the payload's `id` field, and credentials come from the configured auth type (see [Authentication](#authentication)). +These commands push a payload JSON (produced by `ap ai-ws build`) to the AI workspace server resolved from the CLI config (`--display-name`/`--platform`, or the active AI workspace). The organization is derived from the auth token, so **no `--org` flag is needed**. Credentials come from the configured auth type (see [Authentication](#authentication)). ### `ap ai-ws llm-provider push` -Creates/updates an LLM provider with `PUT /api-proxy/api/v1/llm-providers/{id}?organizationId={org}`. The JSON file is sent as the request body unchanged. +Creates an LLM provider with `POST /api/v0.9/llm-providers` (operationId `createLLMProvider`). The JSON file is sent as the request body unchanged. ```shell -ap ai-ws llm-provider push -f --org [--display-name ] [--platform ] [--insecure] +ap ai-ws llm-provider push -f [--display-name ] [--platform ] [--insecure] ``` Examples: ```shell -ap ai-ws llm-provider push -f build/wso2-claude.json --org 99089a17-72e0-4dd8-a2f4-c8dfbb085295 -ap ai-ws llm-provider push -f build/wso2-claude.json --org --display-name my-workspace --platform eu +ap ai-ws llm-provider push -f build/wso2-claude.json +ap ai-ws llm-provider push -f build/wso2-claude.json --display-name my-workspace --platform eu ``` ### `ap ai-ws llm-proxy push` -Creates an LLM proxy with `POST /api-proxy/api/v1/llm-proxies/{id}?organizationId={org}`. The supplied `--project-id` is injected into the payload as `projectId` before it is sent. +Creates an LLM proxy with `POST /api/v0.9/llm-proxies` (operationId `createLLMProxy`). The supplied `--project-id` is injected into the payload as `projectId` before it is sent. ```shell -ap ai-ws llm-proxy push -f --org --project-id [--display-name ] [--platform ] [--insecure] +ap ai-ws llm-proxy push -f --project-id [--display-name ] [--platform ] [--insecure] ``` Examples: ```shell -ap ai-ws llm-proxy push -f build/wso2-openai-proxy.json --org --project-id 550e8400-e29b-41d4-a716-446655440000 +ap ai-ws llm-proxy push -f build/wso2-openai-proxy.json --project-id 550e8400-e29b-41d4-a716-446655440000 ``` ### `ap ai-ws mcp-proxy push` -Creates an MCP proxy with `POST /api-proxy/api/v1/mcp-proxies?organizationId={org}`. Like the LLM proxy, the supplied `--project-id` is injected into the payload as `projectId` before it is sent. +Creates an MCP proxy with `POST /api/v0.9/mcp-proxies` (operationId `createMCPProxy`). Like the LLM proxy, the supplied `--project-id` is injected into the payload as `projectId` before it is sent. ```shell -ap ai-ws mcp-proxy push -f --org --project-id [--display-name ] [--platform ] [--insecure] +ap ai-ws mcp-proxy push -f --project-id [--display-name ] [--platform ] [--insecure] ``` Examples: ```shell -ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --org --project-id 019ecf0e-8237-7153-96d5-bb3934e2c313 +ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --project-id 019ecf0e-8237-7153-96d5-bb3934e2c313 ``` Notes: -- `--file` and `--org` are required for all push commands; `--project-id` is also required for LLM proxies and MCP proxies. -- By default a concise summary line is printed, including the artifact `id` (the value other commands need for `--id`). Pass `--output json` (or `-o json`) to print the full server response instead (useful for piping to `jq`). +- `--file` is required for all push commands; `--project-id` is also required for LLM proxies and MCP proxies. The organization is derived from the auth token, so no `--org` flag is needed. +- By default a structured result is printed (like `ap gateway apply`): `Status`, `Message`, `ID`, and — when known — `Organization`, `Project`, `Created At`, `Updated At`, and `State`. `Project` shows the `--project-id` you supplied (proxies/MCP); `Organization` is derived from the auth token so it only appears when the server echoes `organizationId`. Pass `--output json` (or `-o json`) to print the full server response instead (useful for piping to `jq`). - `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. ## Edit Commands -These commands update an existing artifact on the AI workspace by sending its payload JSON with a `PUT` request to the id-scoped resource path (`/{resource}/{id}?organizationId={org}`). The resource `id` is taken from the payload's `id` field, and the AI workspace and credentials are resolved exactly like the push commands. +These commands update an existing artifact on the AI workspace by sending its payload JSON with a `PUT` request to the id-scoped resource path (`/{resource}/{id}`). The resource `id` is taken from the payload's `id` field, the organization is derived from the auth token (**no `--org` flag**), and the AI workspace and credentials are resolved exactly like the push commands. ### `ap ai-ws llm-provider edit` -Updates an existing LLM provider with `PUT /api-proxy/api/v1/llm-providers/{id}?organizationId={org}`. The JSON file is sent as the request body unchanged. +Updates an existing LLM provider with `PUT /api/v0.9/llm-providers/{id}` (operationId `updateLLMProvider`). The JSON file is sent as the request body unchanged. ```shell -ap ai-ws llm-provider edit -f --org [--display-name ] [--platform ] [--insecure] +ap ai-ws llm-provider edit -f [--display-name ] [--platform ] [--insecure] ``` ### `ap ai-ws llm-proxy edit` -Updates an existing LLM proxy with `PUT /api-proxy/api/v1/llm-proxies/{id}?organizationId={org}`. The supplied `--project-id` is injected into the payload as `projectId` before it is sent. +Updates an existing LLM proxy with `PUT /api/v0.9/llm-proxies/{id}` (operationId `updateLLMProxy`). The supplied `--project-id` is injected into the payload as `projectId` before it is sent. ```shell -ap ai-ws llm-proxy edit -f --org --project-id [--display-name ] [--platform ] [--insecure] +ap ai-ws llm-proxy edit -f --project-id [--display-name ] [--platform ] [--insecure] ``` ### `ap ai-ws mcp-proxy edit` -Updates an existing MCP proxy with `PUT /api-proxy/api/v1/mcp-proxies/{id}?organizationId={org}`. Like the LLM proxy, the supplied `--project-id` is injected into the payload as `projectId` before it is sent. +Updates an existing MCP proxy with `PUT /api/v0.9/mcp-proxies/{id}` (operationId `updateMCPProxy`). Like the LLM proxy, the supplied `--project-id` is injected into the payload as `projectId` before it is sent. ```shell -ap ai-ws mcp-proxy edit -f --org --project-id [--display-name ] [--platform ] [--insecure] +ap ai-ws mcp-proxy edit -f --project-id [--display-name ] [--platform ] [--insecure] ``` Notes: -- `--file` and `--org` are required for all edit commands; `--project-id` is also required for LLM proxies and MCP proxies. +- `--file` is required for all edit commands; `--project-id` is also required for LLM proxies and MCP proxies. The organization is derived from the auth token, so no `--org` flag is needed. - The payload must contain the `id` of the artifact to update; it identifies the resource in the request URL. -- By default a concise summary line is printed, including the artifact `id` (the value other commands need for `--id`). Pass `--output json` (or `-o json`) to print the full server response instead (useful for piping to `jq`). +- By default a structured result is printed (like `ap gateway apply`): `Status`, `Message`, `ID`, and — when known — `Organization`, `Project`, `Created At`, `Updated At`, and `State`. `Project` shows the `--project-id` you supplied (proxies/MCP); `Organization` is derived from the auth token so it only appears when the server echoes `organizationId`. Pass `--output json` (or `-o json`) to print the full server response instead (useful for piping to `jq`). - `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. ## Related Commands diff --git a/docs/cli/end-to-end-workflow.md b/docs/cli/end-to-end-workflow.md index 7182f2da03..4ae94e420a 100644 --- a/docs/cli/end-to-end-workflow.md +++ b/docs/cli/end-to-end-workflow.md @@ -90,11 +90,12 @@ ap devportal rest-api publish -f build/devportal.zip --org ```shell ap ai-ws build # → build/.json -ap ai-ws llm-proxy push -f build/.json --org +ap ai-ws llm-proxy push -f build/.json --project-id # the same pattern applies to: ap ai-ws llm-provider push / ap ai-ws mcp-proxy push +# (the organization comes from the auth token — no --org flag) ``` -`ap ai-ws build` reads each ai-workspace entry in `.api-platform/config.yaml` and generates an LLM proxy creation payload (JSON). Pass `--use-spec` to fold the OpenAPI spec from `definition.yaml` into the payload. +`ap ai-ws build` reads each ai-workspace entry in `.api-platform/config.yaml` and generates a creation payload (JSON), folding the OpenAPI spec from `definition.yaml` into the payload. ## Notes From 971da09bc32a94210dd932cb11c702d6071c736e Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Sun, 5 Jul 2026 20:33:32 +0530 Subject: [PATCH 21/27] add missing list commands for ai-ws --- cli/src/cmd/aiws/llmproxy/list.go | 100 ++++++++++++++++++++++++++++++ cli/src/cmd/aiws/llmproxy/root.go | 1 + cli/src/cmd/aiws/mcpproxy/list.go | 100 ++++++++++++++++++++++++++++++ cli/src/cmd/aiws/mcpproxy/root.go | 1 + docs/cli/ai-workspace/README.md | 17 +++++ 5 files changed, 219 insertions(+) create mode 100644 cli/src/cmd/aiws/llmproxy/list.go create mode 100644 cli/src/cmd/aiws/mcpproxy/list.go diff --git a/cli/src/cmd/aiws/llmproxy/list.go b/cli/src/cmd/aiws/llmproxy/list.go new file mode 100644 index 0000000000..5d93a50256 --- /dev/null +++ b/cli/src/cmd/aiws/llmproxy/list.go @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmproxy + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + ListCmdLiteral = "list" + ListCmdExample = `# List all LLM proxies in a project +ap ai-ws llm-proxy list --project-id + +# List with pagination +ap ai-ws llm-proxy list --project-id --limit 50 --offset 0 + +# List using a specific AI workspace +ap ai-ws llm-proxy list --project-id --display-name my-workspace --platform eu` +) + +var ( + listProjectID string + listLimit string + listOffset string + listName string + listPlatform string + listInsecure bool +) + +var listCmd = &cobra.Command{ + Use: ListCmdLiteral, + Short: "List all LLM proxies in a project", + Long: "Retrieves all LLM proxies for a given project from the WSO2 API Platform AI workspace.", + Example: ListCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runListCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(listCmd, utils.FlagProjectID, &listProjectID, "", "Project ID (required)") + utils.AddStringFlag(listCmd, utils.FlagLimit, &listLimit, "", "Maximum number of proxies to return") + utils.AddStringFlag(listCmd, utils.FlagOffset, &listOffset, "", "Number of proxies to skip") + utils.AddStringFlag(listCmd, utils.FlagName, &listName, "", "AI workspace display name") + utils.AddStringFlag(listCmd, utils.FlagPlatform, &listPlatform, "", "Platform name") + listCmd.Flags().BoolVar(&listInsecure, "insecure", false, "Skip TLS certificate verification") +} + +func runListCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + projectID := strings.TrimSpace(listProjectID) + if projectID == "" { + return fmt.Errorf("project ID is required (use --project-id)") + } + + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, listName, listPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, listInsecure) + path := aiworkspace.ProxyListPath(projectID, aiworkspace.ListQuery{Limit: listLimit, Offset: listOffset}) + + resp, err := client.Get(path) + if err != nil { + return aiworkspace.WrapRequestError("list llm proxies", err, listInsecure) + } + + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiws/llmproxy/root.go b/cli/src/cmd/aiws/llmproxy/root.go index a92caa5eef..351347f71a 100644 --- a/cli/src/cmd/aiws/llmproxy/root.go +++ b/cli/src/cmd/aiws/llmproxy/root.go @@ -42,6 +42,7 @@ var LLMProxyCmd = &cobra.Command{ func init() { LLMProxyCmd.AddCommand(pushCmd) LLMProxyCmd.AddCommand(editCmd) + LLMProxyCmd.AddCommand(listCmd) LLMProxyCmd.AddCommand(getCmd) LLMProxyCmd.AddCommand(deleteCmd) } diff --git a/cli/src/cmd/aiws/mcpproxy/list.go b/cli/src/cmd/aiws/mcpproxy/list.go new file mode 100644 index 0000000000..8e4e2c5f85 --- /dev/null +++ b/cli/src/cmd/aiws/mcpproxy/list.go @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package mcpproxy + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + ListCmdLiteral = "list" + ListCmdExample = `# List all MCP proxies in a project +ap ai-ws mcp-proxy list --project-id + +# List with pagination +ap ai-ws mcp-proxy list --project-id --limit 50 --offset 0 + +# List using a specific AI workspace +ap ai-ws mcp-proxy list --project-id --display-name my-workspace --platform eu` +) + +var ( + listProjectID string + listLimit string + listOffset string + listName string + listPlatform string + listInsecure bool +) + +var listCmd = &cobra.Command{ + Use: ListCmdLiteral, + Short: "List all MCP proxies in a project", + Long: "Retrieves all MCP proxies for a given project from the WSO2 API Platform AI workspace.", + Example: ListCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runListCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(listCmd, utils.FlagProjectID, &listProjectID, "", "Project ID (required)") + utils.AddStringFlag(listCmd, utils.FlagLimit, &listLimit, "", "Maximum number of proxies to return") + utils.AddStringFlag(listCmd, utils.FlagOffset, &listOffset, "", "Number of proxies to skip") + utils.AddStringFlag(listCmd, utils.FlagName, &listName, "", "AI workspace display name") + utils.AddStringFlag(listCmd, utils.FlagPlatform, &listPlatform, "", "Platform name") + listCmd.Flags().BoolVar(&listInsecure, "insecure", false, "Skip TLS certificate verification") +} + +func runListCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + projectID := strings.TrimSpace(listProjectID) + if projectID == "" { + return fmt.Errorf("project ID is required (use --project-id)") + } + + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, listName, listPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, listInsecure) + path := aiworkspace.MCPProxyListPath(projectID, aiworkspace.ListQuery{Limit: listLimit, Offset: listOffset}) + + resp, err := client.Get(path) + if err != nil { + return aiworkspace.WrapRequestError("list mcp proxies", err, listInsecure) + } + + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiws/mcpproxy/root.go b/cli/src/cmd/aiws/mcpproxy/root.go index d99d426bab..246be9c5d6 100644 --- a/cli/src/cmd/aiws/mcpproxy/root.go +++ b/cli/src/cmd/aiws/mcpproxy/root.go @@ -42,6 +42,7 @@ var MCPProxyCmd = &cobra.Command{ func init() { MCPProxyCmd.AddCommand(pushCmd) MCPProxyCmd.AddCommand(editCmd) + MCPProxyCmd.AddCommand(listCmd) MCPProxyCmd.AddCommand(getCmd) MCPProxyCmd.AddCommand(deleteCmd) } diff --git a/docs/cli/ai-workspace/README.md b/docs/cli/ai-workspace/README.md index aeebb43179..c19ce8e120 100644 --- a/docs/cli/ai-workspace/README.md +++ b/docs/cli/ai-workspace/README.md @@ -336,6 +336,14 @@ ap ai-ws llm-provider get [--limit ] [--offset ] [--display-name ] [ ap ai-ws llm-provider get --id ``` +### `ap ai-ws llm-proxy list` + +Lists all LLM proxies in a project (`GET /llm-proxies?projectId={project}`, operationId `listLLMProxies`). `--project-id` is required. + +```shell +ap ai-ws llm-proxy list --project-id [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] +``` + ### `ap ai-ws llm-proxy get` ```shell @@ -346,6 +354,14 @@ ap ai-ws llm-proxy get --project-id [--limit ] [--offset ] [- ap ai-ws llm-proxy get --id ``` +### `ap ai-ws mcp-proxy list` + +Lists all MCP proxies in a project (`GET /mcp-proxies?projectId={project}`, operationId `listMCPProxies`). `--project-id` is required. + +```shell +ap ai-ws mcp-proxy list --project-id [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] +``` + ### `ap ai-ws mcp-proxy get` ```shell @@ -359,6 +375,7 @@ ap ai-ws mcp-proxy get --id Notes: - `llm-provider list` and `llm-provider get` both list providers when no `--id` is given; `list` is the dedicated list-all command, while `get` additionally fetches a single provider with `--id`. Neither needs `--org` (the organization is derived from the auth token). +- `llm-proxy`/`mcp-proxy` each have a dedicated `list` command (project-scoped, `--project-id` required) alongside `get`, which lists when no `--id` is given and fetches a single proxy with `--id`. - For `llm-proxy`/`mcp-proxy get`, `--project-id` is required only when listing; fetching a single proxy needs just `--id`. - `--limit` and `--offset` apply only when listing. - `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. From 2f0646f5ba667d90267e5c0bbc6cb55f6ce3cacb Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Mon, 6 Jul 2026 10:09:26 +0530 Subject: [PATCH 22/27] change ai-ws related command wordings --- cli/src/cmd/aiws/add.go | 14 +-- cli/src/cmd/aiws/build.go | 36 +++--- cli/src/cmd/aiws/current.go | 2 +- cli/src/cmd/aiws/list.go | 2 +- cli/src/cmd/aiws/llmprovider/delete.go | 4 +- cli/src/cmd/aiws/llmprovider/edit.go | 4 +- cli/src/cmd/aiws/llmprovider/get.go | 6 +- cli/src/cmd/aiws/llmprovider/list.go | 6 +- cli/src/cmd/aiws/llmprovider/push.go | 4 +- cli/src/cmd/aiws/llmprovider/root.go | 2 +- cli/src/cmd/aiws/llmproxy/delete.go | 4 +- cli/src/cmd/aiws/llmproxy/edit.go | 4 +- cli/src/cmd/aiws/llmproxy/get.go | 6 +- cli/src/cmd/aiws/llmproxy/list.go | 6 +- cli/src/cmd/aiws/llmproxy/push.go | 4 +- cli/src/cmd/aiws/llmproxy/root.go | 4 +- cli/src/cmd/aiws/mcpproxy/delete.go | 4 +- cli/src/cmd/aiws/mcpproxy/edit.go | 4 +- cli/src/cmd/aiws/mcpproxy/get.go | 6 +- cli/src/cmd/aiws/mcpproxy/list.go | 6 +- cli/src/cmd/aiws/mcpproxy/push.go | 4 +- cli/src/cmd/aiws/mcpproxy/root.go | 2 +- cli/src/cmd/aiws/remove.go | 2 +- cli/src/cmd/aiws/root.go | 4 +- cli/src/cmd/aiws/use.go | 2 +- cli/src/cmd/project/init_test.go | 2 +- cli/src/internal/aiworkspace/client.go | 4 +- cli/src/internal/project/artifact.go | 17 +++ cli/src/internal/project/scaffold.go | 2 +- cli/src/utils/constants.go | 10 +- docs/cli/README.md | 2 +- docs/cli/ai-workspace/README.md | 168 ++++++++++++------------- docs/cli/end-to-end-workflow.md | 16 +-- 33 files changed, 194 insertions(+), 169 deletions(-) diff --git a/cli/src/cmd/aiws/add.go b/cli/src/cmd/aiws/add.go index 7c0418becb..d2626c2df9 100644 --- a/cli/src/cmd/aiws/add.go +++ b/cli/src/cmd/aiws/add.go @@ -31,21 +31,21 @@ import ( const ( AddCmdLiteral = "add" AddCmdExample = `# Add a new AI workspace fully interactively -ap ai-ws add +ap ai-workspace add # Add an AI workspace with basic auth -ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth basic +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth basic # Add an AI workspace with OAuth auth -ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth oauth +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth oauth # Add an AI workspace with API key auth -ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth api-key +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth api-key # Add an AI workspace without interactive prompts using flags -ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth basic --no-interactive --username admin --password admin -ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth oauth --no-interactive --token your_token_here -ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth api-key --no-interactive --api-key your_api_key_here` +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth basic --no-interactive --username admin --password admin +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth oauth --no-interactive --token your_token_here +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth api-key --no-interactive --api-key your_api_key_here` ) var ( diff --git a/cli/src/cmd/aiws/build.go b/cli/src/cmd/aiws/build.go index b01f951edd..14afd6eacb 100644 --- a/cli/src/cmd/aiws/build.go +++ b/cli/src/cmd/aiws/build.go @@ -35,16 +35,16 @@ import ( const ( BuildCmdLiteral = "build" BuildCmdExample = `# Build the AI workspace artifact in the current directory -ap ai-ws build +ap ai-workspace build # Build from a specific project directory -ap ai-ws build -f /path/to/project +ap ai-workspace build -f /path/to/project # Write the generated payload to a specific directory -ap ai-ws build -o build/ +ap ai-workspace build -o build/ # Write the generated payload to a specific file -ap ai-ws build -o build/openai.json` +ap ai-workspace build -o build/openai.json` ) var ( @@ -249,11 +249,15 @@ func buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile string, se return "", fmt.Errorf("ai-workspace config %q: failed to read runtime: %w", config.Name, err) } - // The kind declared in metadata.yaml and runtime.yaml must match. - metadataKind := strings.TrimSpace(metadata.Kind) - runtimeKind := strings.TrimSpace(runtime.Kind) + // The kind declared in metadata.yaml carries a "Metadata" suffix + // (e.g. LlmProxyMetadata) while runtime.yaml uses the base kind (LlmProxy). + // Normalize both to the base kind before comparing and dispatching. + rawMetadataKind := strings.TrimSpace(metadata.Kind) + rawRuntimeKind := strings.TrimSpace(runtime.Kind) + metadataKind := strings.TrimSuffix(rawMetadataKind, metadataKindSuffix) + runtimeKind := strings.TrimSuffix(rawRuntimeKind, metadataKindSuffix) if metadataKind != runtimeKind { - return "", fmt.Errorf("ai-workspace config %q: kind mismatch: metadata.yaml has kind %q but runtime.yaml has kind %q", config.Name, metadataKind, runtimeKind) + return "", fmt.Errorf("ai-workspace config %q: kind mismatch: metadata.yaml has kind %q but runtime.yaml has kind %q", config.Name, rawMetadataKind, rawRuntimeKind) } resourceName := strings.TrimSpace(metadata.Metadata.Name) @@ -327,6 +331,10 @@ const ( kindLLMProxy = "LlmProxy" kindLLMProvider = "LlmProvider" kindMCP = "Mcp" + // metadataKindSuffix is appended to the metadata.yaml kind for ai-workspace + // artifacts (e.g. LlmProxyMetadata) to distinguish it from the runtime kind + // (LlmProxy). It is stripped before comparing/dispatching on the kind. + metadataKindSuffix = "Metadata" ) // loadAIWorkspaceSpec reads the configured definition.yaml relative to baseDir @@ -411,7 +419,7 @@ func buildLLMProviderPayload(name string, metadata aiWorkspaceMetadata, runtime Template: template, OpenAPI: openapi, ModelProviders: modelProvidersForTemplate(template), - AssociatedGateways: normalizeAssociatedGateways(metadata.AssociatedGateways), + AssociatedGateways: normalizeAssociatedGateways(metadata.Spec.AssociatedGateways), } if up := runtime.Spec.Upstream; up != nil { @@ -479,7 +487,7 @@ func buildMCPProxyPayload(name string, metadata aiWorkspaceMetadata, runtime aiW Resources: mcpResources(definition.Resources), Tools: definition.Tools, }, - AssociatedGateways: normalizeAssociatedGateways(metadata.AssociatedGateways), + AssociatedGateways: normalizeAssociatedGateways(metadata.Spec.AssociatedGateways), } if up := runtime.Spec.Upstream; up != nil { @@ -829,7 +837,7 @@ func buildLLMProxyPayload(proxyName string, metadata aiWorkspaceMetadata, runtim OpenAPI: openapi, ReadOnly: false, Provider: llmProxyProvider{ID: strings.TrimSpace(runtime.Spec.Provider.ID)}, - AssociatedGateways: normalizeAssociatedGateways(metadata.AssociatedGateways), + AssociatedGateways: normalizeAssociatedGateways(metadata.Spec.AssociatedGateways), } // The proxy references its provider by id; the provider owns the credential @@ -914,10 +922,10 @@ type aiWorkspaceMetadata struct { Spec struct { DisplayName string `yaml:"displayName"` Version string `yaml:"version"` + // AssociatedGateways lives under spec in metadata.yaml; the build extracts + // it from there and folds it into the generated payload. + AssociatedGateways []associatedGateway `yaml:"associatedGateways"` } `yaml:"spec"` - // AssociatedGateways is a top-level section in metadata.yaml (a sibling of - // spec), not nested under spec. - AssociatedGateways []associatedGateway `yaml:"associatedGateways"` } // associatedGateway mirrors the AssociatedGateway schema (openapi.yaml): the diff --git a/cli/src/cmd/aiws/current.go b/cli/src/cmd/aiws/current.go index 22216db505..ce178ae26a 100644 --- a/cli/src/cmd/aiws/current.go +++ b/cli/src/cmd/aiws/current.go @@ -30,7 +30,7 @@ import ( const ( CurrentCmdLiteral = "current" CurrentCmdExample = `# Show the current active AI workspace -ap ai-ws current` +ap ai-workspace current` ) var currentCmd = &cobra.Command{ diff --git a/cli/src/cmd/aiws/list.go b/cli/src/cmd/aiws/list.go index 38058618f5..2b5883719c 100644 --- a/cli/src/cmd/aiws/list.go +++ b/cli/src/cmd/aiws/list.go @@ -30,7 +30,7 @@ import ( const ( ListCmdLiteral = "list" ListCmdExample = `# List all AI workspaces -ap ai-ws list` +ap ai-workspace list` ) var listCmd = &cobra.Command{ diff --git a/cli/src/cmd/aiws/llmprovider/delete.go b/cli/src/cmd/aiws/llmprovider/delete.go index 0274a41b06..4131f4e05a 100644 --- a/cli/src/cmd/aiws/llmprovider/delete.go +++ b/cli/src/cmd/aiws/llmprovider/delete.go @@ -32,10 +32,10 @@ import ( const ( DeleteCmdLiteral = "delete" DeleteCmdExample = `# Delete an LLM provider by ID using the active AI workspace -ap ai-ws llm-provider delete --id wso2-claude +ap ai-workspace llm-provider delete --id wso2-claude # Delete using a specific AI workspace -ap ai-ws llm-provider delete --id wso2-claude --display-name my-workspace --platform eu` +ap ai-workspace llm-provider delete --id wso2-claude --display-name my-workspace --platform eu` ) var ( diff --git a/cli/src/cmd/aiws/llmprovider/edit.go b/cli/src/cmd/aiws/llmprovider/edit.go index 5dca818eea..49bd38df53 100644 --- a/cli/src/cmd/aiws/llmprovider/edit.go +++ b/cli/src/cmd/aiws/llmprovider/edit.go @@ -33,10 +33,10 @@ import ( const ( EditCmdLiteral = "edit" EditCmdExample = `# Update an existing LLM provider using the active AI workspace -ap ai-ws llm-provider edit -f build/wso2-claude.json +ap ai-workspace llm-provider edit -f build/wso2-claude.json # Update using a specific AI workspace -ap ai-ws llm-provider edit -f build/wso2-claude.json --display-name my-workspace --platform eu` +ap ai-workspace llm-provider edit -f build/wso2-claude.json --display-name my-workspace --platform eu` ) var ( diff --git a/cli/src/cmd/aiws/llmprovider/get.go b/cli/src/cmd/aiws/llmprovider/get.go index 0fb7ab0e21..bd26e0bf85 100644 --- a/cli/src/cmd/aiws/llmprovider/get.go +++ b/cli/src/cmd/aiws/llmprovider/get.go @@ -32,13 +32,13 @@ import ( const ( GetCmdLiteral = "get" GetCmdExample = `# List all LLM providers -ap ai-ws llm-provider get +ap ai-workspace llm-provider get # Get a single LLM provider by ID -ap ai-ws llm-provider get --id wso2-claude +ap ai-workspace llm-provider get --id wso2-claude # List using a specific AI workspace with pagination -ap ai-ws llm-provider get --limit 50 --offset 0 --display-name my-workspace --platform eu` +ap ai-workspace llm-provider get --limit 50 --offset 0 --display-name my-workspace --platform eu` ) var ( diff --git a/cli/src/cmd/aiws/llmprovider/list.go b/cli/src/cmd/aiws/llmprovider/list.go index 5b206fbab8..325e992a73 100644 --- a/cli/src/cmd/aiws/llmprovider/list.go +++ b/cli/src/cmd/aiws/llmprovider/list.go @@ -31,13 +31,13 @@ import ( const ( ListCmdLiteral = "list" ListCmdExample = `# List all LLM providers -ap ai-ws llm-provider list +ap ai-workspace llm-provider list # List with pagination -ap ai-ws llm-provider list --limit 50 --offset 0 +ap ai-workspace llm-provider list --limit 50 --offset 0 # List using a specific AI workspace -ap ai-ws llm-provider list --display-name my-workspace --platform eu` +ap ai-workspace llm-provider list --display-name my-workspace --platform eu` ) var ( diff --git a/cli/src/cmd/aiws/llmprovider/push.go b/cli/src/cmd/aiws/llmprovider/push.go index 42cc85aedc..7687c93ded 100644 --- a/cli/src/cmd/aiws/llmprovider/push.go +++ b/cli/src/cmd/aiws/llmprovider/push.go @@ -33,10 +33,10 @@ import ( const ( PushCmdLiteral = "push" PushCmdExample = `# Push an LLM provider artifact using the active AI workspace -ap ai-ws llm-provider push -f build/wso2-claude.json +ap ai-workspace llm-provider push -f build/wso2-claude.json # Push using a specific AI workspace -ap ai-ws llm-provider push -f build/wso2-claude.json --display-name my-workspace --platform eu` +ap ai-workspace llm-provider push -f build/wso2-claude.json --display-name my-workspace --platform eu` ) var ( diff --git a/cli/src/cmd/aiws/llmprovider/root.go b/cli/src/cmd/aiws/llmprovider/root.go index 99ebf0fde4..aa48bd80f1 100644 --- a/cli/src/cmd/aiws/llmprovider/root.go +++ b/cli/src/cmd/aiws/llmprovider/root.go @@ -25,7 +25,7 @@ import ( const ( LLMProviderCmdLiteral = "llm-provider" LLMProviderCmdExample = `# Push an LLM provider artifact to the AI workspace -ap ai-ws llm-provider push -f build/wso2-claude.json` +ap ai-workspace llm-provider push -f build/wso2-claude.json` ) // LLMProviderCmd is the parent command for LLM provider operations. diff --git a/cli/src/cmd/aiws/llmproxy/delete.go b/cli/src/cmd/aiws/llmproxy/delete.go index 893cfb7fe0..98ac5bc44a 100644 --- a/cli/src/cmd/aiws/llmproxy/delete.go +++ b/cli/src/cmd/aiws/llmproxy/delete.go @@ -32,10 +32,10 @@ import ( const ( DeleteCmdLiteral = "delete" DeleteCmdExample = `# Delete an LLM proxy by ID using the active AI workspace -ap ai-ws llm-proxy delete --id wso2-openai-proxy +ap ai-workspace app-llm-proxy delete --id wso2-openai-proxy # Delete using a specific AI workspace -ap ai-ws llm-proxy delete --id wso2-openai-proxy --display-name my-workspace --platform eu` +ap ai-workspace app-llm-proxy delete --id wso2-openai-proxy --display-name my-workspace --platform eu` ) var ( diff --git a/cli/src/cmd/aiws/llmproxy/edit.go b/cli/src/cmd/aiws/llmproxy/edit.go index f291b93322..892477fa96 100644 --- a/cli/src/cmd/aiws/llmproxy/edit.go +++ b/cli/src/cmd/aiws/llmproxy/edit.go @@ -33,10 +33,10 @@ import ( const ( EditCmdLiteral = "edit" EditCmdExample = `# Update an existing LLM proxy using the active AI workspace -ap ai-ws llm-proxy edit -f build/wso2-openai-proxy.json --project-id +ap ai-workspace app-llm-proxy edit -f build/wso2-openai-proxy.json --project-id # Update using a specific AI workspace -ap ai-ws llm-proxy edit -f build/wso2-openai-proxy.json --project-id --display-name my-workspace --platform eu` +ap ai-workspace app-llm-proxy edit -f build/wso2-openai-proxy.json --project-id --display-name my-workspace --platform eu` ) var ( diff --git a/cli/src/cmd/aiws/llmproxy/get.go b/cli/src/cmd/aiws/llmproxy/get.go index c7f8e02e75..795a72fe2b 100644 --- a/cli/src/cmd/aiws/llmproxy/get.go +++ b/cli/src/cmd/aiws/llmproxy/get.go @@ -32,13 +32,13 @@ import ( const ( GetCmdLiteral = "get" GetCmdExample = `# List all LLM proxies in a project -ap ai-ws llm-proxy get --project-id +ap ai-workspace app-llm-proxy get --project-id # Get a single LLM proxy by ID -ap ai-ws llm-proxy get --id wso2-openai-proxy +ap ai-workspace app-llm-proxy get --id wso2-openai-proxy # List using a specific AI workspace with pagination -ap ai-ws llm-proxy get --project-id --limit 50 --offset 0 --display-name my-workspace --platform eu` +ap ai-workspace app-llm-proxy get --project-id --limit 50 --offset 0 --display-name my-workspace --platform eu` ) var ( diff --git a/cli/src/cmd/aiws/llmproxy/list.go b/cli/src/cmd/aiws/llmproxy/list.go index 5d93a50256..88d6c2346a 100644 --- a/cli/src/cmd/aiws/llmproxy/list.go +++ b/cli/src/cmd/aiws/llmproxy/list.go @@ -32,13 +32,13 @@ import ( const ( ListCmdLiteral = "list" ListCmdExample = `# List all LLM proxies in a project -ap ai-ws llm-proxy list --project-id +ap ai-workspace app-llm-proxy list --project-id # List with pagination -ap ai-ws llm-proxy list --project-id --limit 50 --offset 0 +ap ai-workspace app-llm-proxy list --project-id --limit 50 --offset 0 # List using a specific AI workspace -ap ai-ws llm-proxy list --project-id --display-name my-workspace --platform eu` +ap ai-workspace app-llm-proxy list --project-id --display-name my-workspace --platform eu` ) var ( diff --git a/cli/src/cmd/aiws/llmproxy/push.go b/cli/src/cmd/aiws/llmproxy/push.go index e346bf66fb..5f62ac564b 100644 --- a/cli/src/cmd/aiws/llmproxy/push.go +++ b/cli/src/cmd/aiws/llmproxy/push.go @@ -33,10 +33,10 @@ import ( const ( PushCmdLiteral = "push" PushCmdExample = `# Push an LLM proxy artifact using the active AI workspace -ap ai-ws llm-proxy push -f build/wso2-openai-proxy.json --project-id +ap ai-workspace app-llm-proxy push -f build/wso2-openai-proxy.json --project-id # Push using a specific AI workspace -ap ai-ws llm-proxy push -f build/wso2-openai-proxy.json --project-id --display-name my-workspace --platform eu` +ap ai-workspace app-llm-proxy push -f build/wso2-openai-proxy.json --project-id --display-name my-workspace --platform eu` ) var ( diff --git a/cli/src/cmd/aiws/llmproxy/root.go b/cli/src/cmd/aiws/llmproxy/root.go index 351347f71a..da744c5877 100644 --- a/cli/src/cmd/aiws/llmproxy/root.go +++ b/cli/src/cmd/aiws/llmproxy/root.go @@ -23,9 +23,9 @@ import ( ) const ( - LLMProxyCmdLiteral = "llm-proxy" + LLMProxyCmdLiteral = "app-llm-proxy" LLMProxyCmdExample = `# Push an LLM proxy artifact to the AI workspace -ap ai-ws llm-proxy push -f build/wso2-openai-proxy.json --project-id ` +ap ai-workspace app-llm-proxy push -f build/wso2-openai-proxy.json --project-id ` ) // LLMProxyCmd is the parent command for LLM proxy operations. diff --git a/cli/src/cmd/aiws/mcpproxy/delete.go b/cli/src/cmd/aiws/mcpproxy/delete.go index 1ef378674b..596c10b8bd 100644 --- a/cli/src/cmd/aiws/mcpproxy/delete.go +++ b/cli/src/cmd/aiws/mcpproxy/delete.go @@ -32,10 +32,10 @@ import ( const ( DeleteCmdLiteral = "delete" DeleteCmdExample = `# Delete an MCP proxy by ID using the active AI workspace -ap ai-ws mcp-proxy delete --id bijira-mcp-everything +ap ai-workspace mcp-proxy delete --id bijira-mcp-everything # Delete using a specific AI workspace -ap ai-ws mcp-proxy delete --id bijira-mcp-everything --display-name my-workspace --platform eu` +ap ai-workspace mcp-proxy delete --id bijira-mcp-everything --display-name my-workspace --platform eu` ) var ( diff --git a/cli/src/cmd/aiws/mcpproxy/edit.go b/cli/src/cmd/aiws/mcpproxy/edit.go index 8754a22e75..9d80c26d58 100644 --- a/cli/src/cmd/aiws/mcpproxy/edit.go +++ b/cli/src/cmd/aiws/mcpproxy/edit.go @@ -33,10 +33,10 @@ import ( const ( EditCmdLiteral = "edit" EditCmdExample = `# Update an existing MCP proxy using the active AI workspace -ap ai-ws mcp-proxy edit -f build/bijira-mcp-everything.json --project-id +ap ai-workspace mcp-proxy edit -f build/bijira-mcp-everything.json --project-id # Update using a specific AI workspace -ap ai-ws mcp-proxy edit -f build/bijira-mcp-everything.json --project-id --display-name my-workspace --platform eu` +ap ai-workspace mcp-proxy edit -f build/bijira-mcp-everything.json --project-id --display-name my-workspace --platform eu` ) var ( diff --git a/cli/src/cmd/aiws/mcpproxy/get.go b/cli/src/cmd/aiws/mcpproxy/get.go index 62e6eef42f..c454c18475 100644 --- a/cli/src/cmd/aiws/mcpproxy/get.go +++ b/cli/src/cmd/aiws/mcpproxy/get.go @@ -32,13 +32,13 @@ import ( const ( GetCmdLiteral = "get" GetCmdExample = `# List all MCP proxies in a project -ap ai-ws mcp-proxy get --project-id +ap ai-workspace mcp-proxy get --project-id # Get a single MCP proxy by ID -ap ai-ws mcp-proxy get --id bijira-mcp-everything +ap ai-workspace mcp-proxy get --id bijira-mcp-everything # List using a specific AI workspace with pagination -ap ai-ws mcp-proxy get --project-id --limit 50 --offset 0 --display-name my-workspace --platform eu` +ap ai-workspace mcp-proxy get --project-id --limit 50 --offset 0 --display-name my-workspace --platform eu` ) var ( diff --git a/cli/src/cmd/aiws/mcpproxy/list.go b/cli/src/cmd/aiws/mcpproxy/list.go index 8e4e2c5f85..9479d9e78c 100644 --- a/cli/src/cmd/aiws/mcpproxy/list.go +++ b/cli/src/cmd/aiws/mcpproxy/list.go @@ -32,13 +32,13 @@ import ( const ( ListCmdLiteral = "list" ListCmdExample = `# List all MCP proxies in a project -ap ai-ws mcp-proxy list --project-id +ap ai-workspace mcp-proxy list --project-id # List with pagination -ap ai-ws mcp-proxy list --project-id --limit 50 --offset 0 +ap ai-workspace mcp-proxy list --project-id --limit 50 --offset 0 # List using a specific AI workspace -ap ai-ws mcp-proxy list --project-id --display-name my-workspace --platform eu` +ap ai-workspace mcp-proxy list --project-id --display-name my-workspace --platform eu` ) var ( diff --git a/cli/src/cmd/aiws/mcpproxy/push.go b/cli/src/cmd/aiws/mcpproxy/push.go index 410a330c03..70b0cc6618 100644 --- a/cli/src/cmd/aiws/mcpproxy/push.go +++ b/cli/src/cmd/aiws/mcpproxy/push.go @@ -33,10 +33,10 @@ import ( const ( PushCmdLiteral = "push" PushCmdExample = `# Push an MCP proxy artifact using the active AI workspace -ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --project-id +ap ai-workspace mcp-proxy push -f build/bijira-mcp-everything.json --project-id # Push using a specific AI workspace -ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --project-id --display-name my-workspace --platform eu` +ap ai-workspace mcp-proxy push -f build/bijira-mcp-everything.json --project-id --display-name my-workspace --platform eu` ) var ( diff --git a/cli/src/cmd/aiws/mcpproxy/root.go b/cli/src/cmd/aiws/mcpproxy/root.go index 246be9c5d6..e38a3be86b 100644 --- a/cli/src/cmd/aiws/mcpproxy/root.go +++ b/cli/src/cmd/aiws/mcpproxy/root.go @@ -25,7 +25,7 @@ import ( const ( MCPProxyCmdLiteral = "mcp-proxy" MCPProxyCmdExample = `# Push an MCP proxy artifact to the AI workspace -ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --project-id ` +ap ai-workspace mcp-proxy push -f build/bijira-mcp-everything.json --project-id ` ) // MCPProxyCmd is the parent command for MCP proxy operations. diff --git a/cli/src/cmd/aiws/remove.go b/cli/src/cmd/aiws/remove.go index 009aecb92d..75299f143a 100644 --- a/cli/src/cmd/aiws/remove.go +++ b/cli/src/cmd/aiws/remove.go @@ -30,7 +30,7 @@ import ( const ( RemoveCmdLiteral = "remove" RemoveCmdExample = `# Remove an AI workspace -ap ai-ws remove --display-name my-workspace` +ap ai-workspace remove --display-name my-workspace` ) var ( diff --git a/cli/src/cmd/aiws/root.go b/cli/src/cmd/aiws/root.go index aeb3d1b6aa..17e0c72fdf 100644 --- a/cli/src/cmd/aiws/root.go +++ b/cli/src/cmd/aiws/root.go @@ -26,9 +26,9 @@ import ( ) const ( - AiWSCmdLiteral = "ai-ws" + AiWSCmdLiteral = "ai-workspace" AiWSCmdExample = `# Add a new AI-Workspace -ap ai-ws add --display-name my-portal --platform eu --server https://ai-workspace.example.com --auth api-key` +ap ai-workspace add --display-name my-portal --platform eu --server https://ai-workspace.example.com --auth api-key` ) var AiWSCmd = &cobra.Command{ diff --git a/cli/src/cmd/aiws/use.go b/cli/src/cmd/aiws/use.go index ff998a56f1..8f566979ae 100644 --- a/cli/src/cmd/aiws/use.go +++ b/cli/src/cmd/aiws/use.go @@ -30,7 +30,7 @@ import ( const ( UseCmdLiteral = "use" UseCmdExample = `# Set my-workspace as the active AI workspace -ap ai-ws use --display-name my-workspace` +ap ai-workspace use --display-name my-workspace` ) var ( diff --git a/cli/src/cmd/project/init_test.go b/cli/src/cmd/project/init_test.go index 5d54b8b59e..163ef065af 100644 --- a/cli/src/cmd/project/init_test.go +++ b/cli/src/cmd/project/init_test.go @@ -116,7 +116,7 @@ func TestBuildDirectoryStructureAIWorkspaceMetadata(t *testing.T) { for _, want := range []string{ "apiVersion: ai-workspace.api-platform.wso2.com/v1alpha", - "kind: LlmProxy", + "kind: LlmProxyMetadata", "name: openai-dev-llm", "displayName: OpenAI Dev LLM", } { diff --git a/cli/src/internal/aiworkspace/client.go b/cli/src/internal/aiworkspace/client.go index 911640aecc..e52ba87e77 100644 --- a/cli/src/internal/aiworkspace/client.go +++ b/cli/src/internal/aiworkspace/client.go @@ -16,7 +16,7 @@ * under the License. */ -// Package aiworkspace holds the HTTP client used by the `ap ai-ws` commands to +// Package aiworkspace holds the HTTP client used by the `ap ai-workspace` commands to // talk to an AI Workspace server (LLM proxies / providers). package aiworkspace @@ -191,7 +191,7 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) { } func (c *Client) missingCredsError(authType, envVars string) error { - return fmt.Errorf("authentication credentials not found for ai-workspace '%s' (auth type: %s).\nPlease either:\n - Re-add ai-workspace: ap ai-ws add --display-name %s --server --auth %s\n - Or export: %s", + return fmt.Errorf("authentication credentials not found for ai-workspace '%s' (auth type: %s).\nPlease either:\n - Re-add ai-workspace: ap ai-workspace add --display-name %s --server --auth %s\n - Or export: %s", c.aiWorkspace.Name, authType, c.aiWorkspace.Name, authType, envVars) } diff --git a/cli/src/internal/project/artifact.go b/cli/src/internal/project/artifact.go index 0dfc6df7c1..59313155b5 100644 --- a/cli/src/internal/project/artifact.go +++ b/cli/src/internal/project/artifact.go @@ -82,6 +82,23 @@ func ArtifactKind(artifactType string) string { } } +// MetadataKind returns the kind stamped into an artifact's metadata.yaml. +// AI-workspace metadata carries a "Metadata" suffix (e.g. LlmProxyMetadata) to +// distinguish it from the runtime/gateway kind (which keeps the bare kind); +// management artifacts use the bare kind unchanged. +func MetadataKind(artifactType string) string { + kind := ArtifactKind(artifactType) + if IsAIWorkspaceType(artifactType) { + return kind + MetadataKindSuffix + } + return kind +} + +// MetadataKindSuffix is appended to the metadata.yaml kind for ai-workspace +// artifacts to distinguish it from the runtime kind (e.g. LlmProxyMetadata vs +// LlmProxy). +const MetadataKindSuffix = "Metadata" + // MetadataAPIVersion returns the apiVersion for an artifact's metadata.yaml, // which differs between the management and ai-workspace planes. func MetadataAPIVersion(artifactType string) string { diff --git a/cli/src/internal/project/scaffold.go b/cli/src/internal/project/scaffold.go index b4a18dfa41..aa6b55ce29 100644 --- a/cli/src/internal/project/scaffold.go +++ b/cli/src/internal/project/scaffold.go @@ -154,7 +154,7 @@ func BuildConfigYAML() (string, error) { func BuildMetadataYAML(artifactType, resourceName, displayName string) (string, error) { m := manifest{ APIVersion: MetadataAPIVersion(artifactType), - Kind: ArtifactKind(artifactType), + Kind: MetadataKind(artifactType), Metadata: manifestMeta{Name: resourceName}, } diff --git a/cli/src/utils/constants.go b/cli/src/utils/constants.go index 41fbfe8da4..0db1ae0be3 100644 --- a/cli/src/utils/constants.go +++ b/cli/src/utils/constants.go @@ -101,11 +101,11 @@ const ( MaxTotalUncompressed = 100 * 1024 * 1024 // Maximum total uncompressed size allowed for the archive (100 MB). // Artifact Types - TypeREST = "rest" - TypeSOAP = "soap" - TypeLLMProxy = "llm-proxy" - TypeLLMProvider = "llm-provider" - TypeMCPProxy = "mcp-proxy" + TypeREST = "REST" + TypeSOAP = "SOAP" + TypeLLMProxy = "LLM-Proxy" + TypeLLMProvider = "App-LLM-Provider" + TypeMCPProxy = "MCP-Proxy" ) // PolicyHub REST API defaults and paths diff --git a/docs/cli/README.md b/docs/cli/README.md index 54961626e7..c8cbfbaf6c 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -10,4 +10,4 @@ | [End-to-End Workflow](end-to-end-workflow.md) | The full flow with a diagram: create a project → deploy to the gateway → build and publish to the Developer Portal or an AI Workspace | | [CLI Reference](reference.md) | Full reference for all `ap gateway` sub-commands (add, list, apply, build, MCP, and more) | | [Customizing Gateway Policies](customizing-gateway-policies.md) | Build custom gateway images with local or PolicyHub policies using `ap gateway image build` | -| [AI-Workspace CLI Reference](ai-workspace/README.md) | Manage AI-Workspace connections (`add`, `list`, `use`, `current`, `remove`) and generate LLM proxy creation payloads with `ap ai-ws build` | +| [AI-Workspace CLI Reference](ai-workspace/README.md) | Manage AI-Workspace connections (`add`, `list`, `use`, `current`, `remove`) and generate LLM proxy creation payloads with `ap ai-workspace build` | diff --git a/docs/cli/ai-workspace/README.md b/docs/cli/ai-workspace/README.md index c19ce8e120..ab84dfef3e 100644 --- a/docs/cli/ai-workspace/README.md +++ b/docs/cli/ai-workspace/README.md @@ -4,14 +4,14 @@ This guide covers the AI-Workspace commands currently implemented under `cli/src Available command group: -- `ap ai-ws` +- `ap ai-workspace` The `add`, `list`, `remove`, `use`, and `current` commands manage AI-Workspace **server connections** stored in the CLI config file (the same per-platform config used by `ap gateway` and `ap devportal`). The `build` command is different: it works inside an **API project** and generates an LLM proxy creation payload from the project's artifacts. ## Prerequisites - Add at least one AI-Workspace configuration before using commands that target a specific workspace connection. -- AI-Workspace connections are stored in the CLI config file managed by `ap ai-ws add`. +- AI-Workspace connections are stored in the CLI config file managed by `ap ai-workspace add`. - Commands that use an active AI-Workspace resolve the platform first, then the active AI-Workspace under that platform. ## Authentication @@ -38,22 +38,22 @@ Environment variables override credentials stored in the CLI config. ## Connection Commands -### `ap ai-ws add` +### `ap ai-workspace add` Adds an AI-Workspace configuration to the CLI config file. ```shell -ap ai-ws add --display-name --server --auth [--platform ] [--no-interactive] +ap ai-workspace add --display-name --server --auth [--platform ] [--no-interactive] ``` Examples: ```shell -ap ai-ws add -ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth basic -ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth oauth -ap ai-ws add --display-name my-workspace --server https://ai-workspace.example.com --auth api-key -ap ai-ws add --display-name my-workspace --platform eu --server https://ai-workspace.example.com --auth api-key --no-interactive +ap ai-workspace add +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth basic +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth oauth +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth api-key +ap ai-workspace add --display-name my-workspace --platform eu --server https://ai-workspace.example.com --auth api-key --no-interactive ``` Notes: @@ -62,19 +62,19 @@ Notes: - Supplying credentials as flags is supported, but interactive mode or environment variables are preferred. - If credentials are omitted, runtime commands expect the corresponding environment variables. -### `ap ai-ws list` +### `ap ai-workspace list` Lists AI-Workspace configurations for a platform. ```shell -ap ai-ws list [--platform ] +ap ai-workspace list [--platform ] ``` Examples: ```shell -ap ai-ws list -ap ai-ws list --platform eu +ap ai-workspace list +ap ai-workspace list --platform eu ``` Notes: @@ -82,33 +82,33 @@ Notes: - If `--platform` is omitted, the current platform is used. - The active AI-Workspace is marked in the output table. -### `ap ai-ws remove` +### `ap ai-workspace remove` Removes an AI-Workspace configuration from a platform. ```shell -ap ai-ws remove --display-name [--platform ] +ap ai-workspace remove --display-name [--platform ] ``` Example: ```shell -ap ai-ws remove --display-name my-workspace +ap ai-workspace remove --display-name my-workspace ``` -### `ap ai-ws use` +### `ap ai-workspace use` Sets the active AI-Workspace for a platform. ```shell -ap ai-ws use --display-name [--platform ] +ap ai-workspace use --display-name [--platform ] ``` Examples: ```shell -ap ai-ws use --display-name my-workspace -ap ai-ws use --display-name my-workspace --platform eu +ap ai-workspace use --display-name my-workspace +ap ai-workspace use --display-name my-workspace --platform eu ``` Notes: @@ -116,48 +116,48 @@ Notes: - If `--platform` is omitted, the current platform is used. - The command reports whether credentials will come from environment variables or the stored config. -### `ap ai-ws current` +### `ap ai-workspace current` Shows the active AI-Workspace for a platform. ```shell -ap ai-ws current [--platform ] +ap ai-workspace current [--platform ] ``` Example: ```shell -ap ai-ws current +ap ai-workspace current ``` -## `ap ai-ws build` +## `ap ai-workspace build` -Generates an **LLM proxy**, **LLM provider**, or **MCP proxy** creation payload (JSON) from an API project's artifacts. The payload shape is selected by the `kind` declared in `metadata.yaml`/`runtime.yaml`: +Generates an **LLM proxy**, **LLM provider**, or **MCP proxy** creation payload (JSON) from an API project's artifacts. The payload shape is selected by the `kind` declared in `metadata.yaml`/`runtime.yaml`. In `metadata.yaml` the kind carries a `Metadata` suffix (the ai-workspace metadata kind); `runtime.yaml` uses the bare kind: -- `kind: LlmProxy` → matches the `POST /llm-proxies` request body (`LLMProxy` schema). -- `kind: LlmProvider` → matches the `POST /llm-providers` request body (`LLMProvider` schema). -- `kind: Mcp` → MCP proxy creation payload (capabilities + policies). +- `kind: LlmProxyMetadata` (metadata.yaml) / `LlmProxy` (runtime.yaml) → matches the `POST /llm-proxies` request body (`LLMProxy` schema). +- `kind: LlmProviderMetadata` (metadata.yaml) / `LlmProvider` (runtime.yaml) → matches the `POST /llm-providers` request body (`LLMProvider` schema). +- `kind: McpMetadata` (metadata.yaml) / `Mcp` (runtime.yaml) → MCP proxy creation payload (capabilities + policies). -Any other kind is rejected. +Any other kind is rejected. The `Metadata` suffix is stripped before the metadata and runtime kinds are matched, so the two files still refer to the same artifact (e.g. `LlmProxyMetadata` in metadata.yaml matches `LlmProxy` in runtime.yaml). ```shell -ap ai-ws build [-f ] [-o ] +ap ai-workspace build [-f ] [-o ] ``` Examples: ```shell # Build using the current directory as the project root -ap ai-ws build +ap ai-workspace build # Build from a specific project directory -ap ai-ws build -f /path/to/project +ap ai-workspace build -f /path/to/project # Write the payload to a specific directory -ap ai-ws build -o build/ +ap ai-workspace build -o build/ # Write the payload to a specific file -ap ai-ws build -o build/openai.json +ap ai-workspace build -o build/openai.json ``` ### What it reads @@ -178,7 +178,7 @@ For each configured entry, the build: - Resolves `metadata`, `runtime`, and `definition` relative to that entry's `portalRoot` (defaults: `./metadata.yaml`, `./runtime.yaml`, `./definition.yaml`; `portalRoot` defaults to `.`, the project root). - Requires `metadata.yaml` and `runtime.yaml` to exist. -- Requires the `kind` declared in `metadata.yaml` and `runtime.yaml` to match; otherwise the build fails with a kind-mismatch error. +- Requires the `kind` declared in `metadata.yaml` and `runtime.yaml` to match once the metadata's `Metadata` suffix is stripped (e.g. `LlmProxyMetadata` vs `LlmProxy`); otherwise the build fails with a kind-mismatch error. - Requires `metadata.name` to match between `metadata.yaml` and `runtime.yaml`; otherwise the build fails with a name-mismatch error. - Requires `definition.yaml` (the OpenAPI spec) for every kind — see [The OpenAPI spec](#the-openapi-spec) below. - If no `ai-workspaces` section exists, a single `default` entry (`portalRoot: .`) is created in the project config and used. @@ -187,20 +187,20 @@ All resolved paths are constrained to the project directory; a path that escapes #### Associating gateways (`metadata.yaml`) -Optionally list the gateways the artifact can be deployed to, with per-gateway configuration overrides, in a top-level `associatedGateways` section of `metadata.yaml` (a sibling of `spec`, **not** nested under it). This applies to all artifact kinds (`LlmProxy`, `LlmProvider`, `Mcp`). Each entry is keyed by the gateway `id`. The build copies this list into the generated payload verbatim (entries without an `id` are dropped; the field is omitted entirely when absent): +Optionally list the gateways the artifact can be deployed to, with per-gateway configuration overrides, in an `associatedGateways` section **under `spec`** in `metadata.yaml`. This applies to all artifact kinds (`LlmProxyMetadata`, `LlmProviderMetadata`, `McpMetadata`). Each entry is keyed by the gateway `id`. The build extracts this list from `spec.associatedGateways` and copies it into the generated payload verbatim (entries without an `id` are dropped; the field is omitted entirely when absent): ```yaml # metadata.yaml -kind: LlmProvider +kind: LlmProviderMetadata metadata: name: wso2-claude-provider spec: displayName: wso2 claude provider version: v1.0 -associatedGateways: - - id: default - configurations: - host: prod-gw.example.com + associatedGateways: + - id: default + configurations: + host: prod-gw.example.com ``` `configurations` is a free-form object — the supported keys depend on the artifact type. @@ -224,7 +224,7 @@ One JSON file per configured AI-Workspace entry, written to the build output. | `operationPolicies[]` (`name`, `version`, `paths[].{path,methods,params}`) | `runtime.yaml` → `spec.operationPolicies`; each path's `params` is copied verbatim | | `readOnly` | always `false` | | `openapi` | content of `definition.yaml` (**required**) | -| `associatedGateways[]` (`id`, `configurations`) | `metadata.yaml` → `associatedGateways` (top-level) (omitted when absent) | +| `associatedGateways[]` (`id`, `configurations`) | `metadata.yaml` → `spec.associatedGateways` (omitted when absent) | | `projectId` | intentionally omitted (injected by `push`/`edit` via `--project-id`) | #### `LlmProvider` @@ -243,7 +243,7 @@ One JSON file per configured AI-Workspace entry, written to the build output. | `rateLimiting` | the `*-ratelimit` policies (see below) | | `policies[]` (`name`, `version`, `paths[].{path,methods,params}`) | every other `runtime.yaml` → `spec.policies` entry (i.e. not `api-key-auth` or `*-ratelimit`) | | `openapi` | content of `definition.yaml` (**required** for providers) | -| `associatedGateways[]` (`id`, `configurations`) | `metadata.yaml` → `associatedGateways` (top-level) (omitted when absent) | +| `associatedGateways[]` (`id`, `configurations`) | `metadata.yaml` → `spec.associatedGateways` (omitted when absent) | **rateLimiting mapping.** Each policy whose name ends with `-ratelimit` becomes a rate-limiting dimension, selected by name: @@ -280,7 +280,7 @@ A limit whose path is `/*` is applied as a `global` limit for its scope; a limit | `upstream` (`main.{url,auth}`) | `runtime.yaml` → `spec.upstream` | | `policies[]` (`name`, `version`, `params`) | `runtime.yaml` → `spec.policies` (auth/authz/etc.) | | `capabilities` (`prompts`, `resources`, `tools`) | `definition.yaml` (**required** for MCP) | -| `associatedGateways[]` (`id`, `configurations`) | `metadata.yaml` → `associatedGateways` (top-level) (omitted when absent) | +| `associatedGateways[]` (`id`, `configurations`) | `metadata.yaml` → `spec.associatedGateways` (omitted when absent) | | `description` | empty | `definition.yaml` for an MCP proxy holds `prompts`, `resources`, and `tools`. `prompts` and `tools` are passed through unchanged; `resources` are trimmed to `uri`, `name`, and `mimeType` (any inline `text`/`blob` content is dropped). `projectId` is omitted and injected at publish time. @@ -316,60 +316,60 @@ The scoping query parameter differs by resource: - **LLM providers** need no scoping parameter — the organization is derived from the auth token (`GET /llm-providers`, `GET /llm-providers/{id}`). - **LLM/MCP proxies** are scoped by `projectId` (`--project-id`) when listing; fetching a single proxy by `--id` takes only the id path parameter (no org/project query). -### `ap ai-ws llm-provider list` +### `ap ai-workspace llm-provider list` Lists all LLM providers (`GET /llm-providers`, operationId `listLLMProviders`). The organization comes from the auth token, so no `--org` is needed. ```shell -ap ai-ws llm-provider list [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] +ap ai-workspace llm-provider list [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] ``` -### `ap ai-ws llm-provider get` +### `ap ai-workspace llm-provider get` The organization comes from the auth token, so no `--org` is needed. ```shell # List all LLM providers (GET /llm-providers) -ap ai-ws llm-provider get [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] +ap ai-workspace llm-provider get [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] # Get a single LLM provider (GET /llm-providers/{id}) -ap ai-ws llm-provider get --id +ap ai-workspace llm-provider get --id ``` -### `ap ai-ws llm-proxy list` +### `ap ai-workspace app-llm-proxy list` Lists all LLM proxies in a project (`GET /llm-proxies?projectId={project}`, operationId `listLLMProxies`). `--project-id` is required. ```shell -ap ai-ws llm-proxy list --project-id [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] +ap ai-workspace app-llm-proxy list --project-id [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] ``` -### `ap ai-ws llm-proxy get` +### `ap ai-workspace app-llm-proxy get` ```shell # List all LLM proxies in a project (GET /llm-proxies?projectId={project}) -ap ai-ws llm-proxy get --project-id [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] +ap ai-workspace app-llm-proxy get --project-id [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] # Get a single LLM proxy (GET /llm-proxies/{id}) -ap ai-ws llm-proxy get --id +ap ai-workspace app-llm-proxy get --id ``` -### `ap ai-ws mcp-proxy list` +### `ap ai-workspace mcp-proxy list` Lists all MCP proxies in a project (`GET /mcp-proxies?projectId={project}`, operationId `listMCPProxies`). `--project-id` is required. ```shell -ap ai-ws mcp-proxy list --project-id [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] +ap ai-workspace mcp-proxy list --project-id [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] ``` -### `ap ai-ws mcp-proxy get` +### `ap ai-workspace mcp-proxy get` ```shell # List all MCP proxies in a project (GET /mcp-proxies?projectId={project}) -ap ai-ws mcp-proxy get --project-id [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] +ap ai-workspace mcp-proxy get --project-id [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] # Get a single MCP proxy (GET /mcp-proxies/{id}) -ap ai-ws mcp-proxy get --id +ap ai-workspace mcp-proxy get --id ``` Notes: @@ -384,22 +384,22 @@ Notes: These commands delete an artifact by its identifier (`DELETE /{resource}/{id}`). The artifact is identified solely by `--id` — no organization or project scoping is required — and a successful delete (`204 No Content`) prints a confirmation line. -### `ap ai-ws llm-provider delete` +### `ap ai-workspace llm-provider delete` ```shell -ap ai-ws llm-provider delete --id [--display-name ] [--platform ] [--insecure] +ap ai-workspace llm-provider delete --id [--display-name ] [--platform ] [--insecure] ``` -### `ap ai-ws llm-proxy delete` +### `ap ai-workspace app-llm-proxy delete` ```shell -ap ai-ws llm-proxy delete --id [--display-name ] [--platform ] [--insecure] +ap ai-workspace app-llm-proxy delete --id [--display-name ] [--platform ] [--insecure] ``` -### `ap ai-ws mcp-proxy delete` +### `ap ai-workspace mcp-proxy delete` ```shell -ap ai-ws mcp-proxy delete --id [--display-name ] [--platform ] [--insecure] +ap ai-workspace mcp-proxy delete --id [--display-name ] [--platform ] [--insecure] ``` Notes: @@ -409,49 +409,49 @@ Notes: ## Push Commands -These commands push a payload JSON (produced by `ap ai-ws build`) to the AI workspace server resolved from the CLI config (`--display-name`/`--platform`, or the active AI workspace). The organization is derived from the auth token, so **no `--org` flag is needed**. Credentials come from the configured auth type (see [Authentication](#authentication)). +These commands push a payload JSON (produced by `ap ai-workspace build`) to the AI workspace server resolved from the CLI config (`--display-name`/`--platform`, or the active AI workspace). The organization is derived from the auth token, so **no `--org` flag is needed**. Credentials come from the configured auth type (see [Authentication](#authentication)). -### `ap ai-ws llm-provider push` +### `ap ai-workspace llm-provider push` Creates an LLM provider with `POST /api/v0.9/llm-providers` (operationId `createLLMProvider`). The JSON file is sent as the request body unchanged. ```shell -ap ai-ws llm-provider push -f [--display-name ] [--platform ] [--insecure] +ap ai-workspace llm-provider push -f [--display-name ] [--platform ] [--insecure] ``` Examples: ```shell -ap ai-ws llm-provider push -f build/wso2-claude.json -ap ai-ws llm-provider push -f build/wso2-claude.json --display-name my-workspace --platform eu +ap ai-workspace llm-provider push -f build/wso2-claude.json +ap ai-workspace llm-provider push -f build/wso2-claude.json --display-name my-workspace --platform eu ``` -### `ap ai-ws llm-proxy push` +### `ap ai-workspace app-llm-proxy push` Creates an LLM proxy with `POST /api/v0.9/llm-proxies` (operationId `createLLMProxy`). The supplied `--project-id` is injected into the payload as `projectId` before it is sent. ```shell -ap ai-ws llm-proxy push -f --project-id [--display-name ] [--platform ] [--insecure] +ap ai-workspace app-llm-proxy push -f --project-id [--display-name ] [--platform ] [--insecure] ``` Examples: ```shell -ap ai-ws llm-proxy push -f build/wso2-openai-proxy.json --project-id 550e8400-e29b-41d4-a716-446655440000 +ap ai-workspace app-llm-proxy push -f build/wso2-openai-proxy.json --project-id 550e8400-e29b-41d4-a716-446655440000 ``` -### `ap ai-ws mcp-proxy push` +### `ap ai-workspace mcp-proxy push` Creates an MCP proxy with `POST /api/v0.9/mcp-proxies` (operationId `createMCPProxy`). Like the LLM proxy, the supplied `--project-id` is injected into the payload as `projectId` before it is sent. ```shell -ap ai-ws mcp-proxy push -f --project-id [--display-name ] [--platform ] [--insecure] +ap ai-workspace mcp-proxy push -f --project-id [--display-name ] [--platform ] [--insecure] ``` Examples: ```shell -ap ai-ws mcp-proxy push -f build/bijira-mcp-everything.json --project-id 019ecf0e-8237-7153-96d5-bb3934e2c313 +ap ai-workspace mcp-proxy push -f build/bijira-mcp-everything.json --project-id 019ecf0e-8237-7153-96d5-bb3934e2c313 ``` Notes: @@ -464,28 +464,28 @@ Notes: These commands update an existing artifact on the AI workspace by sending its payload JSON with a `PUT` request to the id-scoped resource path (`/{resource}/{id}`). The resource `id` is taken from the payload's `id` field, the organization is derived from the auth token (**no `--org` flag**), and the AI workspace and credentials are resolved exactly like the push commands. -### `ap ai-ws llm-provider edit` +### `ap ai-workspace llm-provider edit` Updates an existing LLM provider with `PUT /api/v0.9/llm-providers/{id}` (operationId `updateLLMProvider`). The JSON file is sent as the request body unchanged. ```shell -ap ai-ws llm-provider edit -f [--display-name ] [--platform ] [--insecure] +ap ai-workspace llm-provider edit -f [--display-name ] [--platform ] [--insecure] ``` -### `ap ai-ws llm-proxy edit` +### `ap ai-workspace app-llm-proxy edit` Updates an existing LLM proxy with `PUT /api/v0.9/llm-proxies/{id}` (operationId `updateLLMProxy`). The supplied `--project-id` is injected into the payload as `projectId` before it is sent. ```shell -ap ai-ws llm-proxy edit -f --project-id [--display-name ] [--platform ] [--insecure] +ap ai-workspace app-llm-proxy edit -f --project-id [--display-name ] [--platform ] [--insecure] ``` -### `ap ai-ws mcp-proxy edit` +### `ap ai-workspace mcp-proxy edit` Updates an existing MCP proxy with `PUT /api/v0.9/mcp-proxies/{id}` (operationId `updateMCPProxy`). Like the LLM proxy, the supplied `--project-id` is injected into the payload as `projectId` before it is sent. ```shell -ap ai-ws mcp-proxy edit -f --project-id [--display-name ] [--platform ] [--insecure] +ap ai-workspace mcp-proxy edit -f --project-id [--display-name ] [--platform ] [--insecure] ``` Notes: @@ -499,6 +499,6 @@ Notes: - `ap platform add` - `ap platform use` -- `ap ai-ws use` -- `ap ai-ws build` +- `ap ai-workspace use` +- `ap ai-workspace build` - `ap project init` diff --git a/docs/cli/end-to-end-workflow.md b/docs/cli/end-to-end-workflow.md index 4ae94e420a..caf08c5ca2 100644 --- a/docs/cli/end-to-end-workflow.md +++ b/docs/cli/end-to-end-workflow.md @@ -12,7 +12,7 @@ A single API project is the source of truth for all destinations: ```mermaid flowchart LR - A["0 · Set up once
connect + select
gateway · devportal · ai-ws"] --> B["1 · Create
ap project init"] + A["0 · Set up once
connect + select
gateway · devportal · ai-workspace"] --> B["1 · Create
ap project init"] B --> C["Author
metadata.yaml · runtime.yaml · definition.yaml"] C --> D["2 · Deploy
ap gateway apply -f runtime.yaml"] D --> E{"Publish to?"} @@ -24,7 +24,7 @@ flowchart LR subgraph AW["AI Workspace"] direction LR - I["3b · Build
ap ai-ws build"] --> J["4b · Push
ap ai-ws llm-proxy push"] + I["3b · Build
ap ai-workspace build"] --> J["4b · Push
ap ai-workspace app-llm-proxy push"] end E -->|REST API| F @@ -46,7 +46,7 @@ Register and select the servers the CLI talks to. Each connection lives under th ap platform add --display-name --control-plane # optional; if you use platforms ap gateway add --display-name --server && ap gateway use --display-name ap devportal add --display-name --server --auth api-key && ap devportal use --display-name -ap ai-ws add --display-name --server --auth api-key && ap ai-ws use --display-name +ap ai-workspace add --display-name --server --auth api-key && ap ai-workspace use --display-name ``` Commands resolve the **active** gateway / devportal / ai-workspace of the active platform unless you pass `--display-name` (and `--platform`). See [Gateway](gateway/README.md), [DevPortal](devportal/README.md), and [AI-Workspace](ai-workspace/README.md) references. @@ -89,17 +89,17 @@ ap devportal rest-api publish -f build/devportal.zip --org #### AI Workspace ```shell -ap ai-ws build # → build/.json -ap ai-ws llm-proxy push -f build/.json --project-id -# the same pattern applies to: ap ai-ws llm-provider push / ap ai-ws mcp-proxy push +ap ai-workspace build # → build/.json +ap ai-workspace app-llm-proxy push -f build/.json --project-id +# the same pattern applies to: ap ai-workspace llm-provider push / ap ai-workspace mcp-proxy push # (the organization comes from the auth token — no --org flag) ``` -`ap ai-ws build` reads each ai-workspace entry in `.api-platform/config.yaml` and generates a creation payload (JSON), folding the OpenAPI spec from `definition.yaml` into the payload. +`ap ai-workspace build` reads each ai-workspace entry in `.api-platform/config.yaml` and generates a creation payload (JSON), folding the OpenAPI spec from `definition.yaml` into the payload. ## Notes -- `ap devportal gen`, `ap devportal build`, and `ap ai-ws build` all operate on an API project (they require `.api-platform/config.yaml`). +- `ap devportal gen`, `ap devportal build`, and `ap ai-workspace build` all operate on an API project (they require `.api-platform/config.yaml`). - Developer Portal is two stages: `gen` **generates** the editable artifact source under `./devportal`, then `build` **packages** it into `build/devportal.zip`. AI Workspace's `build` generates the JSON payload directly (no separate `gen`). - Both `build` commands write into the project's `build/` directory, one artifact per configured portal entry. - `--org` on the publish/push commands is the target organization in the Developer Portal / AI Workspace. From 09dab7b390f8991677ba7219ce2f298c7be8a066 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Mon, 6 Jul 2026 11:16:12 +0530 Subject: [PATCH 23/27] update push command & build command --- .../references/cli-command-anatomy.md | 351 ++++++++++++++++ cli/src/cmd/aiws/build.go | 377 +++++++++--------- cli/src/cmd/aiws/edit.go | 110 +++++ cli/src/cmd/aiws/llmprovider/edit.go | 106 ----- cli/src/cmd/aiws/llmprovider/push.go | 108 ----- cli/src/cmd/aiws/llmprovider/root.go | 9 +- cli/src/cmd/aiws/llmproxy/edit.go | 123 ------ cli/src/cmd/aiws/llmproxy/push.go | 123 ------ cli/src/cmd/aiws/llmproxy/root.go | 10 +- cli/src/cmd/aiws/mcpproxy/edit.go | 123 ------ cli/src/cmd/aiws/mcpproxy/root.go | 10 +- cli/src/cmd/aiws/{mcpproxy => }/push.go | 75 ++-- cli/src/cmd/aiws/root.go | 2 + cli/src/internal/project/config.go | 9 +- cli/src/internal/project/scaffold.go | 16 +- docs/cli/README.md | 2 +- docs/cli/ai-workspace/README.md | 197 +++------ docs/cli/end-to-end-workflow.md | 19 +- 18 files changed, 790 insertions(+), 980 deletions(-) create mode 100644 .agents/skills/sync-cli-with-openapi/references/cli-command-anatomy.md create mode 100644 cli/src/cmd/aiws/edit.go delete mode 100644 cli/src/cmd/aiws/llmprovider/edit.go delete mode 100644 cli/src/cmd/aiws/llmprovider/push.go delete mode 100644 cli/src/cmd/aiws/llmproxy/edit.go delete mode 100644 cli/src/cmd/aiws/llmproxy/push.go delete mode 100644 cli/src/cmd/aiws/mcpproxy/edit.go rename cli/src/cmd/aiws/{mcpproxy => }/push.go (50%) diff --git a/.agents/skills/sync-cli-with-openapi/references/cli-command-anatomy.md b/.agents/skills/sync-cli-with-openapi/references/cli-command-anatomy.md new file mode 100644 index 0000000000..b4bc70e4a7 --- /dev/null +++ b/.agents/skills/sync-cli-with-openapi/references/cli-command-anatomy.md @@ -0,0 +1,351 @@ +# CLI command anatomy & templates + +Reference for the `sync-cli-with-openapi` skill. All paths are relative to the repo root; CLI Go source lives under `cli/src/` (module `github.com/wso2/api-platform/cli`, so imports are e.g. `.../cli/internal/gateway`, `.../cli/utils`). + +The two command families differ enough that you must not blend them. Pick the family from the spec (see the table in SKILL.md), then copy the closest existing sibling command in the same subcommand group. These templates are the fallback when no close sibling exists. + +--- + +## 1. Directory & registration layout (both families) + +``` +cli/src/cmd// # gateway | devportal | platform | aiws | project + root.go # Cmd cobra group; init() AddCommand(...) each child + .go # a leaf command directly under the family + / # a subcommand GROUP for a resource + root.go # Cmd group; init() AddCommand(create/list/get/update/delete...) + create.go list.go get.go update.go delete.go + commands_test.go # table tests for the group +``` + +Registration chain, bottom-up — a new command is invisible until every link exists: + +1. leaf `.go` defines `var Cmd = &cobra.Command{...}`. +2. group `root.go` `init()` calls `Cmd.AddCommand(Cmd)`. +3. family `root.go` `init()` calls `Cmd.AddCommand(.Cmd)`. +4. `Cmd` is registered on the CLI root in `cli/src/cmd/root.go`. + +A new resource group also needs a `root.go` (`Cmd`), added at link 3. + +### Shared conventions + +- **License header**: every `.go` file opens with the Apache-2.0 WSO2 header block (copyright 2026, WSO2 LLC.). Copy it verbatim from any sibling file. +- **Command consts**: each leaf declares `const CmdLiteral = ""` and `CmdExample = ` + "`" + `# ...\nap ...` + "`" + `. +- **Flag name constants**: never hardcode a flag string. Use a `Flag*` const from `cli/src/utils/flags.go`; add one there if missing. Register flags with `utils.AddStringFlag(cmd, utils.FlagX, &var, "", "")` / `utils.AddBoolFlag(...)`. Enforce required flags with `cmd.MarkFlagRequired(utils.FlagX)`. +- **Run wrapper**: `Run:` calls a `runCommand(...)` that returns `error`; on error print `fmt.Fprintf(os.Stderr, "Error: %v\n", err)` then `os.Exit(1)`. +- **Validate before calling**: check required/mutually-exclusive flags and return a clear `fmt.Errorf` first. + +--- + +## 2. Gateway family (`internal/gateway`) + +Consumes `gateway/gateway-controller/api/management-openapi.yaml`. Targets the selected/active gateway. + +**Key helpers** +- `gateway.AddSelectionFlags(cmd)` — adds the gateway-selection flags. Call in every `init()`. +- `gateway.NewClientFromCommand(cmd)` — builds the client for the selected/active gateway. +- Client verbs: `client.Get(path)`, `client.Post(path, body)`, `client.Put(path, body)`, `client.Delete(path)`, plus `PostYAML`/`PutYAML` for raw YAML bodies. +- `gateway.ParseResourceCR(filePath, expectedKind)` — parses a `--file` custom-resource (YAML/JSON) into `{apiVersion, kind, metadata, spec}`; use `cr.Spec` as the payload. +- `gateway.PrintJSONResponse(resp)` — pretty-prints the response body (pass-through; no typed struct needed). +- **Success check**: `resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices` → error. +- **Paths**: constants in `cli/src/utils/constants.go`, named `GatewayPath` (collection) and `GatewayByIDPath` (has one `%s` per path variable). Add a constant for a new endpoint; build with `fmt.Sprintf(utils.Gateway...ByIDPath, id)`. + +### Template — gateway create-from-CR (`POST` collection) + +```go +// + +package + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/gateway" + "github.com/wso2/api-platform/cli/utils" +) + +const kind = "" // CR kind from the spec + +const ( + CreateCmdLiteral = "create" + CreateCmdExample = `# Create a from a CR file +ap gateway create --file .yaml` +) + +var createFilePath string + +var createCmd = &cobra.Command{ + Use: CreateCmdLiteral, + Short: "Create a on the gateway", + Long: "Creates a new from a custom resource file (YAML or JSON).", + Example: CreateCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runCreateCommand(cmd); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + gateway.AddSelectionFlags(createCmd) + utils.AddStringFlag(createCmd, utils.FlagFile, &createFilePath, "", "Path to the CR file (YAML or JSON)") + createCmd.MarkFlagRequired(utils.FlagFile) +} + +func runCreateCommand(cmd *cobra.Command) error { + if strings.TrimSpace(createFilePath) == "" { + return fmt.Errorf("--%s is required", utils.FlagFile) + } + cr, err := gateway.ParseResourceCR(createFilePath, kind) + if err != nil { + return err + } + // Validate spec-required fields here, e.g.: + // if v, ok := cr.Spec[""].(string); !ok || strings.TrimSpace(v) == "" { + // return fmt.Errorf("invalid %s: spec. is required", kind) + // } + data, err := json.Marshal(cr.Spec) + if err != nil { + return fmt.Errorf("failed to build payload: %w", err) + } + client, err := gateway.NewClientFromCommand(cmd) + if err != nil { + return err + } + resp, err := client.Post(utils.GatewaysPath, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("failed to create : %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("failed to create : received status code %d", resp.StatusCode) + } + fmt.Printf(" %q created successfully.\n", cr.Metadata.Name) + return gateway.PrintJSONResponse(resp) +} +``` + +For **get/list/delete**, copy `cli/src/cmd/gateway/restapi/get.go`, `list.go`, `delete.go` — they show ID-vs-name-lookup, `--format json|yaml` output, and `client.Get`/`client.Delete` against a `...ByIDPath`. + +--- + +## 3. DevPortal family (`internal/devportal`) + +Consumes `portals/developer-portal/docs/devportal-openapi-spec-v1.yaml`. Targets a resolved devportal within a platform. + +**Key helpers** +- `config.LoadConfig()` → `internaldevportal.ResolveDevPortal(cfg, name, platform)` → `(*config.DevPortal, resolvedPlatform, error)`. +- `internaldevportal.NewClientWithOptions(devPortal, insecure)` — client honoring `--insecure`. +- `internaldevportal.OrgScopedPath(orgID, "")` → `/o/{orgId}/devportal/v1/`. The `` is appended as-is, so escape interpolated segments (`"apis/"+url.PathEscape(apiID)`) and you may append a query string. Org-management endpoints use the raw `/organizations...` path instead. +- Client verbs: `client.Get(path)`, `client.PostJSON(path, []byte)`, `client.PutJSON(path, []byte)`, `client.Delete(path)`, `client.PostMultipartFile(path, field, filePath)`, `client.PutMultipartFile(...)`. +- Errors: transport errors → `internaldevportal.WrapRequestError("", err, insecure)`; non-2xx → `utils.FormatHTTPError("", resp, "DevPortal")`. **Check the exact status the spec documents** (`http.StatusOK` for GET, `http.StatusCreated` for a `201` POST, etc.). +- Output: `internaldevportal.PrintJSONResponse(resp)`. +- Standard flags: `--org` (`FlagOrgID`, usually required), `--display-name` (`FlagName`), `--platform` (`FlagPlatform`), `--insecure` (`FlagInsecure`). Add resource flags as needed. + +### Template — devportal create (`POST`, JSON body from flags) + +```go +// + +package + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + internaldevportal "github.com/wso2/api-platform/cli/internal/devportal" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + CreateCmdLiteral = "create" + CreateCmdExample = `# Create a +ap devportal create --org org_1 -- ` +) + +var ( + createOrgID string + create string + createName string + createPlatform string + createInsecure bool +) + +var createCmd = &cobra.Command{ + Use: CreateCmdLiteral, + Short: "Create a DevPortal ", + Long: "Creates a in the selected DevPortal.", + Example: CreateCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runCreateCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(createCmd, utils.FlagOrgID, &createOrgID, "", "Organization ID") + utils.AddStringFlag(createCmd, utils.Flag, &create, "", "") + utils.AddStringFlag(createCmd, utils.FlagName, &createName, "", "DevPortal display name") + utils.AddStringFlag(createCmd, utils.FlagPlatform, &createPlatform, "", "Platform name") + utils.AddBoolFlag(createCmd, utils.FlagInsecure, &createInsecure, false, "Skip TLS certificate verification") + _ = createCmd.MarkFlagRequired(utils.FlagOrgID) +} + +func runCreateCommand() error { + orgID := strings.TrimSpace(createOrgID) + if orgID == "" { + return fmt.Errorf("organization ID is required") + } + payload, err := json.Marshal(map[string]string{"": strings.TrimSpace(create)}) + if err != nil { + return fmt.Errorf("failed to build payload: %w", err) + } + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + devPortal, resolvedPlatform, err := internaldevportal.ResolveDevPortal(cfg, createName, createPlatform) + if err != nil { + return err + } + client := internaldevportal.NewClientWithOptions(devPortal, createInsecure) + resp, err := client.PostJSON(internaldevportal.OrgScopedPath(orgID, ""), payload) + if err != nil { + return internaldevportal.WrapRequestError("create ", err, createInsecure) + } + if resp.StatusCode != http.StatusCreated { + return utils.FormatHTTPError("create ", resp, "DevPortal") + } + fmt.Printf(" created using devportal %s (platform: %s)\n", devPortal.Name, resolvedPlatform) + return internaldevportal.PrintJSONResponse(resp) +} +``` + +For **list/get/delete/update**, copy `cli/src/cmd/devportal/subplan/list.go`, `subplan/get.go`, and `subscription/create.go` — they show `OrgScopedPath`, the resolve flow, and status handling. For `--file` JSON bodies use `internaldevportal.ReadJSONFile(path)`. For custom action sub-paths (e.g. `.../change-plan`, `.../regenerate-token`) build `OrgScopedPath(orgID, "subscriptions/"+url.PathEscape(subID)+"/change-plan")`. + +--- + +## 3b. AI-workspace family (`internal/aiworkspace`) + +Consumes the **LLM/MCP subset** of `platform-api/src/resources/openapi.yaml` — `/llm-providers`, `/llm-proxies`, `/mcp-proxies` (and `llm-provider-templates`) — reached under the `/api-proxy/api/v0.9/` prefix. Command literal is `ap ai-ws`. Existing groups: `llmprovider` (`llm-provider`), `llmproxy` (`llm-proxy`), `mcpproxy` (`mcp-proxy`). Its verb vocabulary differs from gateway/devportal: **`push`** (create-or-update from a `--file` JSON artifact), **`edit`**, `get`, `list`, `delete`. + +**Key helpers** +- `config.LoadConfig()` → `aiworkspace.ResolveAIWorkspace(cfg, name, platform)` → `(*config.AIWorkspace, resolvedPlatform, error)`. +- `aiworkspace.NewClientWithOptions(aiWorkspace, insecure)`. +- Client verbs: `client.Get(path)`, `client.PostJSON(path, []byte)`, `client.PutJSON(path, []byte)`, `client.Delete(path)`. +- **Paths**: constants `AIWorkspacePath` in `cli/src/utils/constants.go` (already carry the `/api-proxy/api/v0.9/` prefix), wrapped by helpers in `cli/src/internal/aiworkspace/helpers.go`: + - collection with org scope: `ProviderPath(orgID)` → `.../llm-providers?organizationId=`. + - single resource: `ProviderResourcePath(orgID, id)` / `ProviderByIDPath(id)`. + - list with pagination: `ProviderListPath(orgID, aiworkspace.ListQuery{Limit, Offset})`; proxies/mcp use `projectId` scope (`ProxyListPath(projectID, q)`). + - Add a matching helper (and constant) for a new resource; **org/project scope is a query parameter (`?organizationId=`/`?projectId=`), never a path segment** — this is the main difference from the devportal family. +- Input: `aiworkspace.ReadJSONFile(filePath)` for `--file` JSON artifacts. +- Auth is handled by the client from workspace config or env vars (`WSO2AP_AIWORKSPACE_USERNAME`/`_PASSWORD`/`_TOKEN`/`_API_KEY`, header `x-wso2-api-key`). +- Standard flags: `--org` (`FlagOrgID`), `--file` (`FlagFile`), `--display-name` (`FlagName`), `--platform` (`FlagPlatform`), `--insecure` (`FlagInsecure`). + +### Template — ai-ws push (create-or-update from `--file`) + +Copy the closest sibling — `cli/src/cmd/aiws/llmprovider/push.go` (create/update), `get.go`, `list.go`, `delete.go`, `edit.go`. The shape: + +```go +func runPushCommand() error { + orgID := strings.TrimSpace(pushOrgID) + if orgID == "" { + return fmt.Errorf("organization ID is required") + } + payload, err := aiworkspace.ReadJSONFile(pushFilePath) + if err != nil { + return err + } + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, pushName, pushPlatform) + if err != nil { + return err + } + client := aiworkspace.NewClientWithOptions(aiWorkspace, pushInsecure) + resp, err := client.PostJSON(aiworkspace.ProviderPath(orgID), payload) // PutJSON(ProviderResourcePath(orgID,id)) for update + if err != nil { + return err + } + // status check + print, matching the sibling command + _ = resp + _ = resolvedPlatform + return nil +} +``` + +Register new commands/groups the same way: group `root.go` `AddCommand`, then `cli/src/cmd/aiws/root.go`. + +## 4. Resource-group `root.go` template + +For a brand-new resource group: + +```go +// + +package + +import "github.com/spf13/cobra" + +const ( + CmdLiteral = "" + CmdExample = `# List all s +ap list` +) + +// Cmd is the command group. +var Cmd = &cobra.Command{ + Use: CmdLiteral, + Short: "Manage s", + Long: "This command allows you to manage s.", + Example: CmdExample, + Run: func(cmd *cobra.Command, args []string) { cmd.Help() }, +} + +func init() { + Cmd.AddCommand(createCmd) + Cmd.AddCommand(listCmd) + Cmd.AddCommand(getCmd) + Cmd.AddCommand(updateCmd) + Cmd.AddCommand(deleteCmd) +} +``` + +Then in `cli/src/cmd//root.go`: import the package and add `Cmd.AddCommand(.Cmd)` inside `init()`. + +--- + +## 5. Tests, docs, build + +- **Tests** — copy the group's `commands_test.go` (e.g. `cli/src/cmd/devportal/subplan/commands_test.go`, `gateway/subscriptionplan` tests). They validate flag wiring, required-flag errors, and payload building. Add a case per new flag/validation branch; update payload expectations on MODIFIED ops. +- **Docs** (hand-maintained) — `docs/cli/gateway/README.md`, `docs/cli/devportal/README.md`, and `docs/cli/reference.md`. Follow the existing "### N. " + `#### CLI Command` + shell block format. Update the short-flag table in `reference.md` only if a short flag changed. +- **Build/verify** — from `cli/`: `make build` (tests then builds) or `make test`. While iterating, from `cli/src/`: `go build ./...` and `go test ./...`. + +## 6. Mapping cheatsheet + +| Spec element | CLI counterpart | +|---|---| +| `path` + method | one client call (`Get`/`PostJSON`/`Post`/`Put`/`Delete`) in one `runCommand` | +| path variable `{id}` | `%s` in a gateway path constant, or `url.PathEscape` inside `OrgScopedPath` | +| query/path parameter | a cobra flag via `utils.Flag*` + `utils.AddStringFlag` | +| `requestBody` schema | the `map`/struct marshalled to JSON, or the CR `spec` (`ParseResourceCR`) | +| required field | a `MarkFlagRequired` / explicit `fmt.Errorf` validation | +| response schema | pass-through `PrintJSONResponse`, or a typed decode struct | +| `201`/`200`/`204` | the exact `resp.StatusCode` compared in the success check | +| OAuth2 scope / security | gateway selection flags, or devportal resolve + `--insecure` (auth handled by the client) | +| new tag / resource | a new subcommand group dir + `root.go`, registered in the family root | diff --git a/cli/src/cmd/aiws/build.go b/cli/src/cmd/aiws/build.go index 14afd6eacb..3e39d1c8e1 100644 --- a/cli/src/cmd/aiws/build.go +++ b/cli/src/cmd/aiws/build.go @@ -27,6 +27,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" "github.com/wso2/api-platform/cli/internal/project" "github.com/wso2/api-platform/cli/utils" "gopkg.in/yaml.v3" @@ -34,32 +35,24 @@ import ( const ( BuildCmdLiteral = "build" - BuildCmdExample = `# Build the AI workspace artifact in the current directory + BuildCmdExample = `# Validate the AI workspace artifact in the current directory ap ai-workspace build -# Build from a specific project directory -ap ai-workspace build -f /path/to/project - -# Write the generated payload to a specific directory -ap ai-workspace build -o build/ - -# Write the generated payload to a specific file -ap ai-workspace build -o build/openai.json` +# Validate from a specific project directory +ap ai-workspace build -f /path/to/project` ) -var ( - buildProjectDir string - buildOutputDir string -) +var buildProjectDir string var buildCmd = &cobra.Command{ Use: BuildCmdLiteral, - Short: "Build the project for AI workspace", - Long: "Build the AI workspace artifact for the project located in the specified directory " + - "(or current directory if not specified). For each ai-workspace configuration in " + - ".api-platform/config.yaml, the command reads its metadata.yaml, runtime.yaml and " + - "definition.yaml and generates a creation payload as a JSON file. The OpenAPI spec from " + - "definition.yaml is folded into the payload's openapi field.", + Short: "Validate the project's AI workspace artifact", + Long: "Validate the AI workspace artifact for the project located in the specified directory " + + "(or the current directory if not specified). The command reads the ai-workspace " + + "configuration in .api-platform/config.yaml and checks that its metadata.yaml, runtime.yaml " + + "and definition.yaml are present, that the metadata and runtime kinds align, and that the " + + "resource name matches. It does not generate or send any artifact — use `ap ai-workspace push` " + + "to generate the creation payload and create the artifact.", Example: BuildCmdExample, Run: func(cmd *cobra.Command, args []string) { if err := runBuildCommand(); err != nil { @@ -71,104 +64,20 @@ var buildCmd = &cobra.Command{ func init() { utils.AddStringFlag(buildCmd, utils.FlagFile, &buildProjectDir, "", "Path to the project directory (defaults to current directory)") - utils.AddStringFlag(buildCmd, utils.FlagOutput, &buildOutputDir, "", "Output path: a .json file to write the payload to, or a directory (defaults to the project build directory)") -} - -// failedWorkspace records an ai-workspace config that could not be built so the -// others can still be generated and the failures reported together. -type failedWorkspace struct { - name string - err error } func runBuildCommand() error { - if buildProjectDir == "" { - buildProjectDir = "." - } - - projectRoot, err := filepath.Abs(buildProjectDir) - if err != nil { - return fmt.Errorf("failed to resolve project directory: %w", err) - } - - projectConfigDir := filepath.Join(projectRoot, ".api-platform") - if _, err := os.Stat(projectConfigDir); os.IsNotExist(err) { - return fmt.Errorf("unable to find project directory, please execute this command inside a project") - } else if err != nil { - return fmt.Errorf("failed to inspect project directory: %w", err) - } - - projectConfigPath := filepath.Join(projectConfigDir, "config.yaml") - if _, err := os.Stat(projectConfigPath); os.IsNotExist(err) { - return fmt.Errorf("unable to find project directory, please execute this command inside a project") - } else if err != nil { - return fmt.Errorf("failed to inspect project config: %w", err) - } - - projectConfig, err := project.Load(projectConfigPath) + projectRoot, wsConfig, err := resolveProjectAIWorkspace(buildProjectDir) if err != nil { return err } - // Create a default ai-workspace config if none exists and persist it so the - // project records the configuration that was built. - if len(projectConfig.AIWorkspaces) == 0 { - projectConfig.AIWorkspaces = append(projectConfig.AIWorkspaces, project.AIWorkspaceConfig{ - Name: "default", - PortalRoot: ".", - }) - if err := project.Save(projectConfigPath, projectConfig); err != nil { - return err - } - } - - for i := range projectConfig.AIWorkspaces { - normalizeAIWorkspaceProjectConfig(&projectConfig.AIWorkspaces[i]) - } - - // Resolve -o into either an explicit output file (when it ends in .json) or - // an output directory. With no -o, payloads land in the project build dir. - outputDir := filepath.Join(projectRoot, "build") - outputFile := "" - if trimmed := strings.TrimSpace(buildOutputDir); trimmed != "" { - resolved, err := filepath.Abs(trimmed) - if err != nil { - return fmt.Errorf("failed to resolve output path: %w", err) - } - if strings.EqualFold(filepath.Ext(resolved), ".json") { - outputFile = resolved - outputDir = filepath.Dir(resolved) - } else { - outputDir = resolved - } - } - - // An explicit output file can only hold a single payload. - if outputFile != "" && len(projectConfig.AIWorkspaces) > 1 { - return fmt.Errorf("output path %q is a file, but %d ai-workspace configurations are defined; use a directory instead", - buildOutputDir, len(projectConfig.AIWorkspaces)) - } - - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("failed to create output directory: %w", err) - } - - outputs, failures := generateAIWorkspaceBuildArtifacts(projectRoot, outputDir, outputFile, projectConfig.AIWorkspaces) - - for _, output := range outputs { - fmt.Printf("AI workspace payload generated at %s\n", output) - } - - if len(failures) > 0 { - messages := make([]string, 0, len(failures)) - for _, failure := range failures { - fmt.Fprintf(os.Stderr, "AI workspace build failed for %q: %v\n", failure.name, failure.err) - messages = append(messages, failure.err.Error()) - } - return fmt.Errorf("failed to build %d of %d ai-workspace configuration(s): %s", - len(failures), len(projectConfig.AIWorkspaces), strings.Join(messages, "; ")) + artifact, err := loadAIWorkspaceArtifact(projectRoot, wsConfig) + if err != nil { + return fmt.Errorf("AI workspace validation failed for %q: %w", wsConfig.Name, err) } + fmt.Printf("AI workspace artifact %q (kind: %s) validated successfully\n", artifact.ResourceName, artifact.BaseKind) return nil } @@ -190,35 +99,83 @@ func normalizeAIWorkspaceProjectConfig(config *project.AIWorkspaceConfig) { } } -func generateAIWorkspaceBuildArtifacts(projectRoot, outputDir, outputFile string, configs []project.AIWorkspaceConfig) ([]string, []failedWorkspace) { - outputs := make([]string, 0, len(configs)) - failures := make([]failedWorkspace, 0) - seen := make(map[string]string, len(configs)) // payload filename -> config name +// aiWorkspaceArtifact holds the validated inputs for the project's ai-workspace +// artifact, loaded from metadata.yaml, runtime.yaml and definition.yaml. It is +// produced by loadAIWorkspaceArtifact (which validates but does not generate a +// payload) and consumed by buildPayload (which generates the creation payload). +// The split lets `build` validate only, while `push`/`edit` validate then +// generate and send. +type aiWorkspaceArtifact struct { + ConfigName string + BaseKind string // LlmProvider / LlmProxy / Mcp ("Metadata" suffix stripped) + ResourceName string // metadata.name; becomes the payload id + metadata aiWorkspaceMetadata + runtime aiWorkspaceRuntime + openapi string // definition.yaml content (folded into provider/proxy payloads) + mcpDef mcpDefinition // parsed definition, populated for the Mcp kind +} + +// resolveProjectAIWorkspace resolves the project root from projectDir, loads the +// project config, ensures a single ai-workspace configuration exists (creating a +// default and persisting it when absent), normalizes it, and returns the project +// root and the ai-workspace config entry. Shared by build, push and edit. +func resolveProjectAIWorkspace(projectDir string) (string, *project.AIWorkspaceConfig, error) { + if strings.TrimSpace(projectDir) == "" { + projectDir = "." + } + projectRoot, err := filepath.Abs(projectDir) + if err != nil { + return "", nil, fmt.Errorf("failed to resolve project directory: %w", err) + } - for i := range configs { - outputPath, err := buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile, seen, &configs[i]) - if err != nil { - failures = append(failures, failedWorkspace{name: configs[i].Name, err: err}) - continue + projectConfigDir := filepath.Join(projectRoot, ".api-platform") + if _, err := os.Stat(projectConfigDir); os.IsNotExist(err) { + return "", nil, fmt.Errorf("unable to find project directory, please execute this command inside a project") + } else if err != nil { + return "", nil, fmt.Errorf("failed to inspect project directory: %w", err) + } + + projectConfigPath := filepath.Join(projectConfigDir, "config.yaml") + if _, err := os.Stat(projectConfigPath); os.IsNotExist(err) { + return "", nil, fmt.Errorf("unable to find project directory, please execute this command inside a project") + } else if err != nil { + return "", nil, fmt.Errorf("failed to inspect project config: %w", err) + } + + projectConfig, err := project.Load(projectConfigPath) + if err != nil { + return "", nil, err + } + + // A project has at most one ai-workspace config. Create a default one if it + // is absent and persist it so the project records the configuration used. + if projectConfig.AIWorkspace == nil { + projectConfig.AIWorkspace = &project.AIWorkspaceConfig{ + Name: "default", + PortalRoot: ".", + } + if err := project.Save(projectConfigPath, projectConfig); err != nil { + return "", nil, err } - outputs = append(outputs, outputPath) } - return outputs, failures + normalizeAIWorkspaceProjectConfig(projectConfig.AIWorkspace) + return projectRoot, projectConfig.AIWorkspace, nil } -// buildSingleAIWorkspacePayload reads the metadata.yaml and runtime.yaml for one -// ai-workspace config, derives the creation payload, folds in the OpenAPI spec -// from definition.yaml, and writes it as JSON. When outputFile is set it is -// written there; otherwise it lands at outputDir/.json. Any existing -// file is overwritten. Returning an error drops only this config. -func buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile string, seen map[string]string, config *project.AIWorkspaceConfig) (string, error) { +// loadAIWorkspaceArtifact reads and validates the ai-workspace artifact for the +// given config: it checks that metadata.yaml, runtime.yaml and definition.yaml +// are present and within the project, that the metadata/runtime kinds align +// (after stripping the metadata "Metadata" suffix), that metadata.name is set +// and matches between the two files, and (for the Mcp kind) that the definition +// parses. It does NOT generate the creation payload — call buildPayload for that. +func loadAIWorkspaceArtifact(projectRoot string, config *project.AIWorkspaceConfig) (*aiWorkspaceArtifact, error) { baseDir := resolveProjectPath(projectRoot, config.PortalRoot) if err := ensureWithinProjectRoot(projectRoot, baseDir, config.Name, "portalRoot"); err != nil { - return "", err + return nil, err } if err := ensurePathExists(baseDir, true, config.Name, "portalRoot"); err != nil { - return "", err + return nil, err } metadataPath := resolveProjectPath(baseDir, config.FilePaths.Metadata) @@ -233,20 +190,20 @@ func buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile string, se {label: "runtime", path: runtimePath}, } { if err := ensureWithinProjectRoot(projectRoot, required.path, config.Name, required.label); err != nil { - return "", err + return nil, err } if err := ensurePathExists(required.path, false, config.Name, required.label); err != nil { - return "", err + return nil, err } } var metadata aiWorkspaceMetadata if err := readYAMLFile(metadataPath, &metadata); err != nil { - return "", fmt.Errorf("ai-workspace config %q: failed to read metadata: %w", config.Name, err) + return nil, fmt.Errorf("ai-workspace config %q: failed to read metadata: %w", config.Name, err) } var runtime aiWorkspaceRuntime if err := readYAMLFile(runtimePath, &runtime); err != nil { - return "", fmt.Errorf("ai-workspace config %q: failed to read runtime: %w", config.Name, err) + return nil, fmt.Errorf("ai-workspace config %q: failed to read runtime: %w", config.Name, err) } // The kind declared in metadata.yaml carries a "Metadata" suffix @@ -257,72 +214,131 @@ func buildSingleAIWorkspacePayload(projectRoot, outputDir, outputFile string, se metadataKind := strings.TrimSuffix(rawMetadataKind, metadataKindSuffix) runtimeKind := strings.TrimSuffix(rawRuntimeKind, metadataKindSuffix) if metadataKind != runtimeKind { - return "", fmt.Errorf("ai-workspace config %q: kind mismatch: metadata.yaml has kind %q but runtime.yaml has kind %q", config.Name, rawMetadataKind, rawRuntimeKind) + return nil, fmt.Errorf("ai-workspace config %q: kind mismatch: metadata.yaml has kind %q but runtime.yaml has kind %q", config.Name, rawMetadataKind, rawRuntimeKind) } resourceName := strings.TrimSpace(metadata.Metadata.Name) if resourceName == "" { - return "", fmt.Errorf("ai-workspace config %q is invalid: metadata.metadata.name is required", config.Name) + return nil, fmt.Errorf("ai-workspace config %q is invalid: metadata.metadata.name is required", config.Name) } - - // metadata.name must match between metadata.yaml and runtime.yaml. if runtimeName := strings.TrimSpace(runtime.Metadata.Name); runtimeName != resourceName { - return "", fmt.Errorf("ai-workspace config %q: name mismatch: metadata.yaml has metadata.name %q but runtime.yaml has metadata.name %q", config.Name, resourceName, runtimeName) + return nil, fmt.Errorf("ai-workspace config %q: name mismatch: metadata.yaml has metadata.name %q but runtime.yaml has metadata.name %q", config.Name, resourceName, runtimeName) } - // The payload shape is driven by the declared kind. All kinds fold in the - // OpenAPI spec from definition.yaml, which is required. - var payload interface{} + artifact := &aiWorkspaceArtifact{ + ConfigName: config.Name, + BaseKind: metadataKind, + ResourceName: resourceName, + metadata: metadata, + runtime: runtime, + } + + // The OpenAPI spec (definition.yaml) is required for every kind. + spec, err := loadAIWorkspaceSpec(projectRoot, baseDir, config, true) + if err != nil { + return nil, err + } + artifact.openapi = spec + switch metadataKind { - case kindLLMProxy: - openapi, err := loadAIWorkspaceSpec(projectRoot, baseDir, config, true) - if err != nil { - return "", err - } - payload = buildLLMProxyPayload(resourceName, metadata, runtime, openapi) - case kindLLMProvider: - openapi, err := loadAIWorkspaceSpec(projectRoot, baseDir, config, true) - if err != nil { - return "", err - } - payload = buildLLMProviderPayload(resourceName, metadata, runtime, openapi) + case kindLLMProxy, kindLLMProvider: + // Folded into the payload verbatim; no further parsing at build/validate. case kindMCP: - // An MCP proxy always needs the definition (its capabilities live there). - spec, err := loadAIWorkspaceSpec(projectRoot, baseDir, config, true) - if err != nil { - return "", err - } + // An MCP proxy's capabilities live in the definition, so it must parse. var definition mcpDefinition if err := yaml.Unmarshal([]byte(spec), &definition); err != nil { - return "", fmt.Errorf("ai-workspace config %q: failed to parse definition: %w", config.Name, err) + return nil, fmt.Errorf("ai-workspace config %q: failed to parse definition: %w", config.Name, err) } - payload = buildMCPProxyPayload(resourceName, metadata, runtime, definition) + artifact.mcpDef = definition default: - return "", fmt.Errorf("ai-workspace config %q: unsupported kind %q (supported: %s, %s, %s)", config.Name, metadataKind, kindLLMProxy, kindLLMProvider, kindMCP) + return nil, fmt.Errorf("ai-workspace config %q: unsupported kind %q (supported: %s, %s, %s)", config.Name, metadataKind, kindLLMProxy, kindLLMProvider, kindMCP) } - // An explicit -o file path wins; otherwise the artifact is named after the - // ai-workspace config name (not metadata.name) under the output directory, - // guarding against collisions. - outputPath := outputFile - if outputPath == "" { - fileName := payloadFileName(config.Name) - if existing, ok := seen[fileName]; ok { - return "", fmt.Errorf("payload file %q is already produced by config %q; rename one of the ai-workspace configurations to avoid overwriting the artifact", fileName, existing) - } - seen[fileName] = config.Name - outputPath = filepath.Join(outputDir, fileName) + return artifact, nil +} + +// buildPayload generates the creation payload for the validated artifact. The +// payload shape is driven by the artifact kind. Returns nil for an unsupported +// kind (loadAIWorkspaceArtifact already rejects those, so callers can treat nil +// as a programming error). +func (a *aiWorkspaceArtifact) buildPayload() interface{} { + switch a.BaseKind { + case kindLLMProxy: + return buildLLMProxyPayload(a.ResourceName, a.metadata, a.runtime, a.openapi) + case kindLLMProvider: + return buildLLMProviderPayload(a.ResourceName, a.metadata, a.runtime, a.openapi) + case kindMCP: + return buildMCPProxyPayload(a.ResourceName, a.metadata, a.runtime, a.mcpDef) + default: + return nil + } +} + +// marshalAIWorkspacePayload generates the creation payload from the validated +// artifact and returns it as JSON. LlmProxy and Mcp artifacts are project-scoped +// and require a projectId (from --project-id) injected into the body; providers +// are not project-scoped. Returns the JSON body and the projectId used (empty +// for providers). +func marshalAIWorkspacePayload(artifact *aiWorkspaceArtifact, projectIDFlag string) ([]byte, string, error) { + payload := artifact.buildPayload() + if payload == nil { + return nil, "", fmt.Errorf("unsupported kind %q", artifact.BaseKind) + } + + raw, err := json.Marshal(payload) + if err != nil { + return nil, "", fmt.Errorf("failed to encode payload: %w", err) + } + + // Providers are not project-scoped; send the payload as generated. + if artifact.BaseKind == kindLLMProvider { + return raw, "", nil } - data, err := json.MarshalIndent(payload, "", " ") + // Proxies and MCP proxies require a projectId. Inject it without dropping any + // generated fields. + projectID := strings.TrimSpace(projectIDFlag) + if projectID == "" { + return nil, "", fmt.Errorf("project ID is required for %s artifacts (use --project-id)", artifact.BaseKind) + } + var m map[string]interface{} + if err := json.Unmarshal(raw, &m); err != nil { + return nil, "", fmt.Errorf("failed to encode payload: %w", err) + } + m["projectId"] = projectID + body, err := json.Marshal(m) if err != nil { - return "", fmt.Errorf("ai-workspace config %q: failed to marshal payload: %w", config.Name, err) + return nil, "", fmt.Errorf("failed to encode payload: %w", err) } - if err := os.WriteFile(outputPath, append(data, '\n'), 0644); err != nil { - return "", fmt.Errorf("ai-workspace config %q: failed to write payload: %w", config.Name, err) + return body, projectID, nil +} + +// aiWorkspaceCreatePath returns the collection endpoint to POST to for the kind. +func aiWorkspaceCreatePath(baseKind string) string { + switch baseKind { + case kindLLMProvider: + return aiworkspace.ProviderPath() + case kindLLMProxy: + return aiworkspace.ProxyPath() + case kindMCP: + return aiworkspace.MCPProxyPath() + default: + return "" } +} - return outputPath, nil +// aiWorkspaceUpdatePath returns the by-id endpoint to PUT to for the kind. +func aiWorkspaceUpdatePath(baseKind, id string) string { + switch baseKind { + case kindLLMProvider: + return aiworkspace.ProviderByIDPath(id) + case kindLLMProxy: + return aiworkspace.ProxyByIDPath(id) + case kindMCP: + return aiworkspace.MCPProxyByIDPath(id) + default: + return "" + } } // Supported artifact kinds. These match the `kind` declared in metadata.yaml @@ -890,17 +906,6 @@ func buildSecurityFromGlobalPolicies(policies []runtimeProviderPolicy) *security return nil } -func payloadFileName(name string) string { - sanitized := strings.TrimSpace(name) - sanitized = strings.ReplaceAll(sanitized, string(os.PathSeparator), "-") - sanitized = strings.ReplaceAll(sanitized, "/", "-") - sanitized = strings.ReplaceAll(sanitized, "\\", "-") - if sanitized == "" { - sanitized = "ai-workspace" - } - return sanitized + ".json" -} - func readYAMLFile(path string, out interface{}) error { data, err := os.ReadFile(path) if err != nil { diff --git a/cli/src/cmd/aiws/edit.go b/cli/src/cmd/aiws/edit.go new file mode 100644 index 0000000000..9a0a4550fe --- /dev/null +++ b/cli/src/cmd/aiws/edit.go @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + EditCmdLiteral = "edit" + EditCmdExample = `# Generate and update the AI workspace artifact from the current project +ap ai-workspace edit + +# Update a proxy or MCP artifact (--project-id is required for those kinds) +ap ai-workspace edit --project-id + +# Update from a specific project directory using a specific AI workspace +ap ai-workspace edit -f /path/to/project --project-id --display-name my-workspace --platform eu` +) + +var ( + editProjectDir string + editProjectID string + editName string + editPlatform string + editInsecure bool + editOutput string +) + +var editCmd = &cobra.Command{ + Use: EditCmdLiteral, + Short: "Generate and update an existing AI workspace artifact", + Long: "Generate the payload from the project's metadata.yaml, runtime.yaml and definition.yaml, then " + + "update the existing artifact on the WSO2 API Platform AI workspace with a PUT request. The target " + + "endpoint is selected by the artifact kind declared in the project (LlmProvider, LlmProxy, Mcp) and the " + + "artifact is identified by metadata.name. For the LlmProxy and Mcp kinds --project-id is required.", + Example: EditCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runEditCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(editCmd, utils.FlagFile, &editProjectDir, "", "Path to the project directory (defaults to current directory)") + utils.AddStringFlag(editCmd, utils.FlagProjectID, &editProjectID, "", "Project ID (required for LlmProxy and Mcp kinds)") + utils.AddStringFlag(editCmd, utils.FlagName, &editName, "", "AI workspace display name") + utils.AddStringFlag(editCmd, utils.FlagPlatform, &editPlatform, "", "Platform name") + utils.AddStringFlag(editCmd, utils.FlagOutput, &editOutput, "", "Output format: \"json\" prints the full server response (default: summary)") + editCmd.Flags().BoolVar(&editInsecure, "insecure", false, "Skip TLS certificate verification") +} + +func runEditCommand() error { + projectRoot, wsConfig, err := resolveProjectAIWorkspace(editProjectDir) + if err != nil { + return err + } + + artifact, err := loadAIWorkspaceArtifact(projectRoot, wsConfig) + if err != nil { + return fmt.Errorf("AI workspace validation failed for %q: %w", wsConfig.Name, err) + } + + body, projectID, err := marshalAIWorkspacePayload(artifact, editProjectID) + if err != nil { + return err + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, editName, editPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, editInsecure) + resp, err := client.PutJSON(aiWorkspaceUpdatePath(artifact.BaseKind, artifact.ResourceName), body) + if err != nil { + return aiworkspace.WrapRequestError(fmt.Sprintf("update %s", artifact.BaseKind), err, editInsecure) + } + + return aiworkspace.PrintApplyResult(resp, editOutput, artifact.BaseKind, "updated", artifact.ResourceName, projectID) +} diff --git a/cli/src/cmd/aiws/llmprovider/edit.go b/cli/src/cmd/aiws/llmprovider/edit.go deleted file mode 100644 index 49bd38df53..0000000000 --- a/cli/src/cmd/aiws/llmprovider/edit.go +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package llmprovider - -import ( - "encoding/json" - "fmt" - "os" - "strings" - - "github.com/spf13/cobra" - "github.com/wso2/api-platform/cli/internal/aiworkspace" - "github.com/wso2/api-platform/cli/internal/config" - "github.com/wso2/api-platform/cli/utils" -) - -const ( - EditCmdLiteral = "edit" - EditCmdExample = `# Update an existing LLM provider using the active AI workspace -ap ai-workspace llm-provider edit -f build/wso2-claude.json - -# Update using a specific AI workspace -ap ai-workspace llm-provider edit -f build/wso2-claude.json --display-name my-workspace --platform eu` -) - -var ( - editFilePath string - editName string - editPlatform string - editInsecure bool - editOutput string -) - -var editCmd = &cobra.Command{ - Use: EditCmdLiteral, - Short: "Update an existing LLM provider on the AI workspace", - Long: "Update an existing LLM provider on the WSO2 API Platform AI workspace by sending its JSON payload with a PUT request. The provider is identified by the \"id\" field in the payload.", - Example: EditCmdExample, - Run: func(cmd *cobra.Command, args []string) { - if err := runEditCommand(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - }, -} - -func init() { - utils.AddStringFlag(editCmd, utils.FlagFile, &editFilePath, "", "Path to the LLM provider payload JSON file (required)") - utils.AddStringFlag(editCmd, utils.FlagName, &editName, "", "AI workspace display name") - utils.AddStringFlag(editCmd, utils.FlagPlatform, &editPlatform, "", "Platform name") - utils.AddStringFlag(editCmd, utils.FlagOutput, &editOutput, "", "Output format: \"json\" prints the full server response (default: summary)") - editCmd.Flags().BoolVar(&editInsecure, "insecure", false, "Skip TLS certificate verification") - _ = editCmd.MarkFlagRequired(utils.FlagFile) -} - -func runEditCommand() error { - payload, err := aiworkspace.ReadJSONFile(editFilePath) - if err != nil { - return err - } - - var meta struct { - ID string `json:"id"` - } - if err := json.Unmarshal(payload, &meta); err != nil { - return fmt.Errorf("failed to parse payload: %w", err) - } - providerID := strings.TrimSpace(meta.ID) - if providerID == "" { - return fmt.Errorf("payload is missing the required \"id\" field") - } - - cfg, err := config.LoadConfig() - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, editName, editPlatform) - if err != nil { - return err - } - - client := aiworkspace.NewClientWithOptions(aiWorkspace, editInsecure) - resp, err := client.PutJSON(aiworkspace.ProviderByIDPath(providerID), payload) - if err != nil { - return aiworkspace.WrapRequestError("update llm provider", err, editInsecure) - } - - return aiworkspace.PrintApplyResult(resp, editOutput, "LlmProvider", "updated", providerID, "") -} diff --git a/cli/src/cmd/aiws/llmprovider/push.go b/cli/src/cmd/aiws/llmprovider/push.go deleted file mode 100644 index 7687c93ded..0000000000 --- a/cli/src/cmd/aiws/llmprovider/push.go +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package llmprovider - -import ( - "encoding/json" - "fmt" - "os" - "strings" - - "github.com/spf13/cobra" - "github.com/wso2/api-platform/cli/internal/aiworkspace" - "github.com/wso2/api-platform/cli/internal/config" - "github.com/wso2/api-platform/cli/utils" -) - -const ( - PushCmdLiteral = "push" - PushCmdExample = `# Push an LLM provider artifact using the active AI workspace -ap ai-workspace llm-provider push -f build/wso2-claude.json - -# Push using a specific AI workspace -ap ai-workspace llm-provider push -f build/wso2-claude.json --display-name my-workspace --platform eu` -) - -var ( - pushFilePath string - pushName string - pushPlatform string - pushInsecure bool - pushOutput string -) - -var pushCmd = &cobra.Command{ - Use: PushCmdLiteral, - Short: "Push an LLM provider artifact to the AI workspace", - Long: "Push a generated LLM provider creation payload (JSON) to the WSO2 API Platform AI workspace.", - Example: PushCmdExample, - Run: func(cmd *cobra.Command, args []string) { - if err := runPushCommand(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - }, -} - -func init() { - utils.AddStringFlag(pushCmd, utils.FlagFile, &pushFilePath, "", "Path to the LLM provider payload JSON file (required)") - utils.AddStringFlag(pushCmd, utils.FlagName, &pushName, "", "AI workspace display name") - utils.AddStringFlag(pushCmd, utils.FlagPlatform, &pushPlatform, "", "Platform name") - utils.AddStringFlag(pushCmd, utils.FlagOutput, &pushOutput, "", "Output format: \"json\" prints the full server response (default: summary)") - pushCmd.Flags().BoolVar(&pushInsecure, "insecure", false, "Skip TLS certificate verification") - _ = pushCmd.MarkFlagRequired(utils.FlagFile) -} - -func runPushCommand() error { - payload, err := aiworkspace.ReadJSONFile(pushFilePath) - if err != nil { - return err - } - - var meta struct { - ID string `json:"id"` - Name string `json:"name"` - } - if err := json.Unmarshal(payload, &meta); err != nil { - return fmt.Errorf("failed to parse payload: %w", err) - } - providerID := strings.TrimSpace(meta.ID) - if providerID == "" { - providerName := strings.TrimSpace(meta.Name) - providerID = aiworkspace.ResourceID(providerName, "llm-provider") - } - - cfg, err := config.LoadConfig() - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, pushName, pushPlatform) - if err != nil { - return err - } - - client := aiworkspace.NewClientWithOptions(aiWorkspace, pushInsecure) - resp, err := client.PostJSON(aiworkspace.ProviderPath(), payload) - if err != nil { - return aiworkspace.WrapRequestError("push llm provider", err, pushInsecure) - } - - return aiworkspace.PrintApplyResult(resp, pushOutput, "LlmProvider", "applied", providerID, "") -} diff --git a/cli/src/cmd/aiws/llmprovider/root.go b/cli/src/cmd/aiws/llmprovider/root.go index aa48bd80f1..d9181b0889 100644 --- a/cli/src/cmd/aiws/llmprovider/root.go +++ b/cli/src/cmd/aiws/llmprovider/root.go @@ -24,8 +24,11 @@ import ( const ( LLMProviderCmdLiteral = "llm-provider" - LLMProviderCmdExample = `# Push an LLM provider artifact to the AI workspace -ap ai-workspace llm-provider push -f build/wso2-claude.json` + LLMProviderCmdExample = `# List LLM providers on the AI workspace +ap ai-workspace llm-provider list + +# Create or update a provider from a project with: +# ap ai-workspace push / ap ai-workspace edit` ) // LLMProviderCmd is the parent command for LLM provider operations. @@ -40,8 +43,6 @@ var LLMProviderCmd = &cobra.Command{ } func init() { - LLMProviderCmd.AddCommand(pushCmd) - LLMProviderCmd.AddCommand(editCmd) LLMProviderCmd.AddCommand(listCmd) LLMProviderCmd.AddCommand(getCmd) LLMProviderCmd.AddCommand(deleteCmd) diff --git a/cli/src/cmd/aiws/llmproxy/edit.go b/cli/src/cmd/aiws/llmproxy/edit.go deleted file mode 100644 index 892477fa96..0000000000 --- a/cli/src/cmd/aiws/llmproxy/edit.go +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package llmproxy - -import ( - "encoding/json" - "fmt" - "os" - "strings" - - "github.com/spf13/cobra" - "github.com/wso2/api-platform/cli/internal/aiworkspace" - "github.com/wso2/api-platform/cli/internal/config" - "github.com/wso2/api-platform/cli/utils" -) - -const ( - EditCmdLiteral = "edit" - EditCmdExample = `# Update an existing LLM proxy using the active AI workspace -ap ai-workspace app-llm-proxy edit -f build/wso2-openai-proxy.json --project-id - -# Update using a specific AI workspace -ap ai-workspace app-llm-proxy edit -f build/wso2-openai-proxy.json --project-id --display-name my-workspace --platform eu` -) - -var ( - editFilePath string - editProjectID string - editName string - editPlatform string - editInsecure bool - editOutput string -) - -var editCmd = &cobra.Command{ - Use: EditCmdLiteral, - Short: "Update an existing LLM proxy on the AI workspace", - Long: "Update an existing LLM proxy on the WSO2 API Platform AI workspace by sending its JSON payload with a PUT request. The proxy is identified by the \"id\" field in the payload and the supplied project ID is injected into the payload before it is sent.", - Example: EditCmdExample, - Run: func(cmd *cobra.Command, args []string) { - if err := runEditCommand(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - }, -} - -func init() { - utils.AddStringFlag(editCmd, utils.FlagFile, &editFilePath, "", "Path to the LLM proxy payload JSON file (required)") - utils.AddStringFlag(editCmd, utils.FlagProjectID, &editProjectID, "", "Project ID to set on the payload (required)") - utils.AddStringFlag(editCmd, utils.FlagName, &editName, "", "AI workspace display name") - utils.AddStringFlag(editCmd, utils.FlagPlatform, &editPlatform, "", "Platform name") - utils.AddStringFlag(editCmd, utils.FlagOutput, &editOutput, "", "Output format: \"json\" prints the full server response (default: summary)") - editCmd.Flags().BoolVar(&editInsecure, "insecure", false, "Skip TLS certificate verification") - _ = editCmd.MarkFlagRequired(utils.FlagFile) - _ = editCmd.MarkFlagRequired(utils.FlagProjectID) -} - -func runEditCommand() error { - projectID := strings.TrimSpace(editProjectID) - if projectID == "" { - return fmt.Errorf("project ID is required") - } - - raw, err := aiworkspace.ReadJSONFile(editFilePath) - if err != nil { - return err - } - - // Decode into a map so the project ID can be injected without dropping any - // fields the build emitted. - var payload map[string]interface{} - if err := json.Unmarshal(raw, &payload); err != nil { - return fmt.Errorf("failed to parse payload: %w", err) - } - - proxyID, _ := payload["id"].(string) - proxyID = strings.TrimSpace(proxyID) - if proxyID == "" { - return fmt.Errorf("payload is missing the required \"id\" field") - } - - payload["projectId"] = projectID - - body, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to encode payload: %w", err) - } - - cfg, err := config.LoadConfig() - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, editName, editPlatform) - if err != nil { - return err - } - - client := aiworkspace.NewClientWithOptions(aiWorkspace, editInsecure) - resp, err := client.PutJSON(aiworkspace.ProxyByIDPath(proxyID), body) - if err != nil { - return aiworkspace.WrapRequestError("update llm proxy", err, editInsecure) - } - - return aiworkspace.PrintApplyResult(resp, editOutput, "LlmProxy", "updated", proxyID, projectID) -} diff --git a/cli/src/cmd/aiws/llmproxy/push.go b/cli/src/cmd/aiws/llmproxy/push.go deleted file mode 100644 index 5f62ac564b..0000000000 --- a/cli/src/cmd/aiws/llmproxy/push.go +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package llmproxy - -import ( - "encoding/json" - "fmt" - "os" - "strings" - - "github.com/spf13/cobra" - "github.com/wso2/api-platform/cli/internal/aiworkspace" - "github.com/wso2/api-platform/cli/internal/config" - "github.com/wso2/api-platform/cli/utils" -) - -const ( - PushCmdLiteral = "push" - PushCmdExample = `# Push an LLM proxy artifact using the active AI workspace -ap ai-workspace app-llm-proxy push -f build/wso2-openai-proxy.json --project-id - -# Push using a specific AI workspace -ap ai-workspace app-llm-proxy push -f build/wso2-openai-proxy.json --project-id --display-name my-workspace --platform eu` -) - -var ( - pushFilePath string - pushProjectID string - pushName string - pushPlatform string - pushInsecure bool - pushOutput string -) - -var pushCmd = &cobra.Command{ - Use: PushCmdLiteral, - Short: "Push an LLM proxy artifact to the AI workspace", - Long: "Push a generated LLM proxy creation payload (JSON) to the WSO2 API Platform AI workspace. The supplied project ID is injected into the payload before it is sent.", - Example: PushCmdExample, - Run: func(cmd *cobra.Command, args []string) { - if err := runPushCommand(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - }, -} - -func init() { - utils.AddStringFlag(pushCmd, utils.FlagFile, &pushFilePath, "", "Path to the LLM proxy payload JSON file (required)") - utils.AddStringFlag(pushCmd, utils.FlagProjectID, &pushProjectID, "", "Project ID to set on the payload (required)") - utils.AddStringFlag(pushCmd, utils.FlagName, &pushName, "", "AI workspace display name") - utils.AddStringFlag(pushCmd, utils.FlagPlatform, &pushPlatform, "", "Platform name") - utils.AddStringFlag(pushCmd, utils.FlagOutput, &pushOutput, "", "Output format: \"json\" prints the full server response (default: summary)") - pushCmd.Flags().BoolVar(&pushInsecure, "insecure", false, "Skip TLS certificate verification") - _ = pushCmd.MarkFlagRequired(utils.FlagFile) - _ = pushCmd.MarkFlagRequired(utils.FlagProjectID) -} - -func runPushCommand() error { - projectID := strings.TrimSpace(pushProjectID) - if projectID == "" { - return fmt.Errorf("project ID is required") - } - - raw, err := aiworkspace.ReadJSONFile(pushFilePath) - if err != nil { - return err - } - - // Decode into a map so the project ID can be injected without dropping any - // fields the build emitted. - var payload map[string]interface{} - if err := json.Unmarshal(raw, &payload); err != nil { - return fmt.Errorf("failed to parse payload: %w", err) - } - - proxyID, _ := payload["id"].(string) - proxyID = strings.TrimSpace(proxyID) - if proxyID == "" { - return fmt.Errorf("payload is missing the required \"id\" field") - } - - payload["projectId"] = projectID - - body, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to encode payload: %w", err) - } - - cfg, err := config.LoadConfig() - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, pushName, pushPlatform) - if err != nil { - return err - } - - client := aiworkspace.NewClientWithOptions(aiWorkspace, pushInsecure) - resp, err := client.PostJSON(aiworkspace.ProxyPath(), body) - if err != nil { - return aiworkspace.WrapRequestError("push llm proxy", err, pushInsecure) - } - - return aiworkspace.PrintApplyResult(resp, pushOutput, "LlmProxy", "applied", proxyID, projectID) -} diff --git a/cli/src/cmd/aiws/llmproxy/root.go b/cli/src/cmd/aiws/llmproxy/root.go index da744c5877..e2ae41b510 100644 --- a/cli/src/cmd/aiws/llmproxy/root.go +++ b/cli/src/cmd/aiws/llmproxy/root.go @@ -24,8 +24,12 @@ import ( const ( LLMProxyCmdLiteral = "app-llm-proxy" - LLMProxyCmdExample = `# Push an LLM proxy artifact to the AI workspace -ap ai-workspace app-llm-proxy push -f build/wso2-openai-proxy.json --project-id ` + LLMProxyCmdExample = `# List LLM proxies in a project on the AI workspace +ap ai-workspace app-llm-proxy list --project-id + +# Create or update a proxy from a project with: +# ap ai-workspace push --project-id +# ap ai-workspace edit --project-id ` ) // LLMProxyCmd is the parent command for LLM proxy operations. @@ -40,8 +44,6 @@ var LLMProxyCmd = &cobra.Command{ } func init() { - LLMProxyCmd.AddCommand(pushCmd) - LLMProxyCmd.AddCommand(editCmd) LLMProxyCmd.AddCommand(listCmd) LLMProxyCmd.AddCommand(getCmd) LLMProxyCmd.AddCommand(deleteCmd) diff --git a/cli/src/cmd/aiws/mcpproxy/edit.go b/cli/src/cmd/aiws/mcpproxy/edit.go deleted file mode 100644 index 9d80c26d58..0000000000 --- a/cli/src/cmd/aiws/mcpproxy/edit.go +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package mcpproxy - -import ( - "encoding/json" - "fmt" - "os" - "strings" - - "github.com/spf13/cobra" - "github.com/wso2/api-platform/cli/internal/aiworkspace" - "github.com/wso2/api-platform/cli/internal/config" - "github.com/wso2/api-platform/cli/utils" -) - -const ( - EditCmdLiteral = "edit" - EditCmdExample = `# Update an existing MCP proxy using the active AI workspace -ap ai-workspace mcp-proxy edit -f build/bijira-mcp-everything.json --project-id - -# Update using a specific AI workspace -ap ai-workspace mcp-proxy edit -f build/bijira-mcp-everything.json --project-id --display-name my-workspace --platform eu` -) - -var ( - editFilePath string - editProjectID string - editName string - editPlatform string - editInsecure bool - editOutput string -) - -var editCmd = &cobra.Command{ - Use: EditCmdLiteral, - Short: "Update an existing MCP proxy on the AI workspace", - Long: "Update an existing MCP proxy on the WSO2 API Platform AI workspace by sending its JSON payload with a PUT request. The proxy is identified by the \"id\" field in the payload and the supplied project ID is injected into the payload before it is sent.", - Example: EditCmdExample, - Run: func(cmd *cobra.Command, args []string) { - if err := runEditCommand(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - }, -} - -func init() { - utils.AddStringFlag(editCmd, utils.FlagFile, &editFilePath, "", "Path to the MCP proxy payload JSON file (required)") - utils.AddStringFlag(editCmd, utils.FlagProjectID, &editProjectID, "", "Project ID to set on the payload (required)") - utils.AddStringFlag(editCmd, utils.FlagName, &editName, "", "AI workspace display name") - utils.AddStringFlag(editCmd, utils.FlagPlatform, &editPlatform, "", "Platform name") - utils.AddStringFlag(editCmd, utils.FlagOutput, &editOutput, "", "Output format: \"json\" prints the full server response (default: summary)") - editCmd.Flags().BoolVar(&editInsecure, "insecure", false, "Skip TLS certificate verification") - _ = editCmd.MarkFlagRequired(utils.FlagFile) - _ = editCmd.MarkFlagRequired(utils.FlagProjectID) -} - -func runEditCommand() error { - projectID := strings.TrimSpace(editProjectID) - if projectID == "" { - return fmt.Errorf("project ID is required") - } - - raw, err := aiworkspace.ReadJSONFile(editFilePath) - if err != nil { - return err - } - - // Decode into a map so the project ID can be injected without dropping any - // fields the build emitted. - var payload map[string]interface{} - if err := json.Unmarshal(raw, &payload); err != nil { - return fmt.Errorf("failed to parse payload: %w", err) - } - - proxyID, _ := payload["id"].(string) - proxyID = strings.TrimSpace(proxyID) - if proxyID == "" { - return fmt.Errorf("payload is missing the required \"id\" field") - } - - payload["projectId"] = projectID - - body, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to encode payload: %w", err) - } - - cfg, err := config.LoadConfig() - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, editName, editPlatform) - if err != nil { - return err - } - - client := aiworkspace.NewClientWithOptions(aiWorkspace, editInsecure) - resp, err := client.PutJSON(aiworkspace.MCPProxyByIDPath(proxyID), body) - if err != nil { - return aiworkspace.WrapRequestError("update mcp proxy", err, editInsecure) - } - - return aiworkspace.PrintApplyResult(resp, editOutput, "Mcp", "updated", proxyID, projectID) -} diff --git a/cli/src/cmd/aiws/mcpproxy/root.go b/cli/src/cmd/aiws/mcpproxy/root.go index e38a3be86b..abd29f499f 100644 --- a/cli/src/cmd/aiws/mcpproxy/root.go +++ b/cli/src/cmd/aiws/mcpproxy/root.go @@ -24,8 +24,12 @@ import ( const ( MCPProxyCmdLiteral = "mcp-proxy" - MCPProxyCmdExample = `# Push an MCP proxy artifact to the AI workspace -ap ai-workspace mcp-proxy push -f build/bijira-mcp-everything.json --project-id ` + MCPProxyCmdExample = `# List MCP proxies in a project on the AI workspace +ap ai-workspace mcp-proxy list --project-id + +# Create or update an MCP proxy from a project with: +# ap ai-workspace push --project-id +# ap ai-workspace edit --project-id ` ) // MCPProxyCmd is the parent command for MCP proxy operations. @@ -40,8 +44,6 @@ var MCPProxyCmd = &cobra.Command{ } func init() { - MCPProxyCmd.AddCommand(pushCmd) - MCPProxyCmd.AddCommand(editCmd) MCPProxyCmd.AddCommand(listCmd) MCPProxyCmd.AddCommand(getCmd) MCPProxyCmd.AddCommand(deleteCmd) diff --git a/cli/src/cmd/aiws/mcpproxy/push.go b/cli/src/cmd/aiws/push.go similarity index 50% rename from cli/src/cmd/aiws/mcpproxy/push.go rename to cli/src/cmd/aiws/push.go index 70b0cc6618..1af405950c 100644 --- a/cli/src/cmd/aiws/mcpproxy/push.go +++ b/cli/src/cmd/aiws/push.go @@ -16,13 +16,11 @@ * under the License. */ -package mcpproxy +package aiws import ( - "encoding/json" "fmt" "os" - "strings" "github.com/spf13/cobra" "github.com/wso2/api-platform/cli/internal/aiworkspace" @@ -32,26 +30,32 @@ import ( const ( PushCmdLiteral = "push" - PushCmdExample = `# Push an MCP proxy artifact using the active AI workspace -ap ai-workspace mcp-proxy push -f build/bijira-mcp-everything.json --project-id + PushCmdExample = `# Generate and push the AI workspace artifact from the current project +ap ai-workspace push -# Push using a specific AI workspace -ap ai-workspace mcp-proxy push -f build/bijira-mcp-everything.json --project-id --display-name my-workspace --platform eu` +# Push a proxy or MCP artifact (--project-id is required for those kinds) +ap ai-workspace push --project-id + +# Push from a specific project directory using a specific AI workspace +ap ai-workspace push -f /path/to/project --project-id --display-name my-workspace --platform eu` ) var ( - pushFilePath string - pushProjectID string - pushName string - pushPlatform string - pushInsecure bool - pushOutput string + pushProjectDir string + pushProjectID string + pushName string + pushPlatform string + pushInsecure bool + pushOutput string ) var pushCmd = &cobra.Command{ - Use: PushCmdLiteral, - Short: "Push an MCP proxy artifact to the AI workspace", - Long: "Push a generated MCP proxy creation payload (JSON) to the WSO2 API Platform AI workspace. The supplied project ID is injected into the payload before it is sent.", + Use: PushCmdLiteral, + Short: "Generate and push an AI workspace artifact", + Long: "Generate the creation payload from the project's metadata.yaml, runtime.yaml and definition.yaml, " + + "then create the artifact on the WSO2 API Platform AI workspace. The target endpoint is selected by the " + + "artifact kind declared in the project (LlmProvider, LlmProxy, Mcp). For the LlmProxy and Mcp kinds " + + "--project-id is required.", Example: PushCmdExample, Run: func(cmd *cobra.Command, args []string) { if err := runPushCommand(); err != nil { @@ -62,45 +66,28 @@ var pushCmd = &cobra.Command{ } func init() { - utils.AddStringFlag(pushCmd, utils.FlagFile, &pushFilePath, "", "Path to the MCP proxy payload JSON file (required)") - utils.AddStringFlag(pushCmd, utils.FlagProjectID, &pushProjectID, "", "Project ID to set on the payload (required)") + utils.AddStringFlag(pushCmd, utils.FlagFile, &pushProjectDir, "", "Path to the project directory (defaults to current directory)") + utils.AddStringFlag(pushCmd, utils.FlagProjectID, &pushProjectID, "", "Project ID (required for LlmProxy and Mcp kinds)") utils.AddStringFlag(pushCmd, utils.FlagName, &pushName, "", "AI workspace display name") utils.AddStringFlag(pushCmd, utils.FlagPlatform, &pushPlatform, "", "Platform name") utils.AddStringFlag(pushCmd, utils.FlagOutput, &pushOutput, "", "Output format: \"json\" prints the full server response (default: summary)") pushCmd.Flags().BoolVar(&pushInsecure, "insecure", false, "Skip TLS certificate verification") - _ = pushCmd.MarkFlagRequired(utils.FlagFile) - _ = pushCmd.MarkFlagRequired(utils.FlagProjectID) } func runPushCommand() error { - projectID := strings.TrimSpace(pushProjectID) - if projectID == "" { - return fmt.Errorf("project ID is required") - } - - raw, err := aiworkspace.ReadJSONFile(pushFilePath) + projectRoot, wsConfig, err := resolveProjectAIWorkspace(pushProjectDir) if err != nil { return err } - // Decode into a map so the project ID can be injected without dropping any - // fields the build emitted. - var payload map[string]interface{} - if err := json.Unmarshal(raw, &payload); err != nil { - return fmt.Errorf("failed to parse payload: %w", err) - } - - proxyID, _ := payload["id"].(string) - proxyID = strings.TrimSpace(proxyID) - if proxyID == "" { - return fmt.Errorf("payload is missing the required \"id\" field") + artifact, err := loadAIWorkspaceArtifact(projectRoot, wsConfig) + if err != nil { + return fmt.Errorf("AI workspace validation failed for %q: %w", wsConfig.Name, err) } - payload["projectId"] = projectID - - body, err := json.Marshal(payload) + body, projectID, err := marshalAIWorkspacePayload(artifact, pushProjectID) if err != nil { - return fmt.Errorf("failed to encode payload: %w", err) + return err } cfg, err := config.LoadConfig() @@ -114,10 +101,10 @@ func runPushCommand() error { } client := aiworkspace.NewClientWithOptions(aiWorkspace, pushInsecure) - resp, err := client.PostJSON(aiworkspace.MCPProxyPath(), body) + resp, err := client.PostJSON(aiWorkspaceCreatePath(artifact.BaseKind), body) if err != nil { - return aiworkspace.WrapRequestError("push mcp proxy", err, pushInsecure) + return aiworkspace.WrapRequestError(fmt.Sprintf("push %s", artifact.BaseKind), err, pushInsecure) } - return aiworkspace.PrintApplyResult(resp, pushOutput, "Mcp", "applied", proxyID, projectID) + return aiworkspace.PrintApplyResult(resp, pushOutput, artifact.BaseKind, "applied", artifact.ResourceName, projectID) } diff --git a/cli/src/cmd/aiws/root.go b/cli/src/cmd/aiws/root.go index 17e0c72fdf..cc4b49d506 100644 --- a/cli/src/cmd/aiws/root.go +++ b/cli/src/cmd/aiws/root.go @@ -48,6 +48,8 @@ func init() { AiWSCmd.AddCommand(useCmd) AiWSCmd.AddCommand(currentCmd) AiWSCmd.AddCommand(buildCmd) + AiWSCmd.AddCommand(pushCmd) + AiWSCmd.AddCommand(editCmd) AiWSCmd.AddCommand(llmprovider.LLMProviderCmd) AiWSCmd.AddCommand(llmproxy.LLMProxyCmd) AiWSCmd.AddCommand(mcpproxy.MCPProxyCmd) diff --git a/cli/src/internal/project/config.go b/cli/src/internal/project/config.go index eee281d805..d57ffb58dd 100644 --- a/cli/src/internal/project/config.go +++ b/cli/src/internal/project/config.go @@ -90,8 +90,9 @@ type AIWorkspaceFilePaths struct { Definition string `yaml:"definition,omitempty"` } -// AIWorkspaceConfig is one ai-workspace portal configuration in the project -// config. +// AIWorkspaceConfig is the ai-workspace portal configuration in the project +// config. A project can have at most one ai-workspace configuration (unlike +// devportals, which are a list). type AIWorkspaceConfig struct { Name string `yaml:"name,omitempty"` PortalRoot string `yaml:"portalRoot,omitempty"` @@ -105,7 +106,9 @@ type Config struct { GovernanceRulesets []string `yaml:"governanceRulesets"` AutoSync map[string]interface{} `yaml:"autoSync,omitempty"` DevPortals []PortalConfig `yaml:"devportals,omitempty"` - AIWorkspaces []AIWorkspaceConfig `yaml:"ai-workspaces,omitempty"` + // AIWorkspace is a single configuration (an object, not a list): a project + // can have at most one ai-workspace configuration. + AIWorkspace *AIWorkspaceConfig `yaml:"ai-workspace,omitempty"` } // DefaultFilePaths returns the project-level file paths used when scaffolding a diff --git a/cli/src/internal/project/scaffold.go b/cli/src/internal/project/scaffold.go index aa6b55ce29..5d01222919 100644 --- a/cli/src/internal/project/scaffold.go +++ b/cli/src/internal/project/scaffold.go @@ -100,14 +100,14 @@ type runtimeOperation struct { // ai-workspace and devportal publishing targets. Uncomment and adjust to add a // portal; the keys match the structs the build commands parse. const portalConfigTemplate = ` -# AI-Workspace portal configurations -# ai-workspaces: -# - name: dev -# portalRoot: ./ai-workspace -# filePaths: # paths relative to portal root -# metadata: ./artifact.yaml -# runtime: ./runtime.yaml -# definition: ./definition.yaml # OpenAPI spec folded into the payload +# AI-Workspace portal configuration (a single object; a project has at most one) +# ai-workspace: +# name: dev +# portalRoot: ./ai-workspace +# filePaths: # paths relative to portal root +# metadata: ./artifact.yaml +# runtime: ./runtime.yaml +# definition: ./definition.yaml # OpenAPI spec folded into the payload # Dev portal configurations # devportals: diff --git a/docs/cli/README.md b/docs/cli/README.md index c8cbfbaf6c..2c580cbe47 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -10,4 +10,4 @@ | [End-to-End Workflow](end-to-end-workflow.md) | The full flow with a diagram: create a project → deploy to the gateway → build and publish to the Developer Portal or an AI Workspace | | [CLI Reference](reference.md) | Full reference for all `ap gateway` sub-commands (add, list, apply, build, MCP, and more) | | [Customizing Gateway Policies](customizing-gateway-policies.md) | Build custom gateway images with local or PolicyHub policies using `ap gateway image build` | -| [AI-Workspace CLI Reference](ai-workspace/README.md) | Manage AI-Workspace connections (`add`, `list`, `use`, `current`, `remove`) and generate LLM proxy creation payloads with `ap ai-workspace build` | +| [AI-Workspace CLI Reference](ai-workspace/README.md) | Manage AI-Workspace connections (`add`, `list`, `use`, `current`, `remove`), validate a project artifact with `ap ai-workspace build`, and create/update artifacts with `ap ai-workspace push`/`edit` | diff --git a/docs/cli/ai-workspace/README.md b/docs/cli/ai-workspace/README.md index ab84dfef3e..7b2e786a4b 100644 --- a/docs/cli/ai-workspace/README.md +++ b/docs/cli/ai-workspace/README.md @@ -6,7 +6,7 @@ Available command group: - `ap ai-workspace` -The `add`, `list`, `remove`, `use`, and `current` commands manage AI-Workspace **server connections** stored in the CLI config file (the same per-platform config used by `ap gateway` and `ap devportal`). The `build` command is different: it works inside an **API project** and generates an LLM proxy creation payload from the project's artifacts. +The `add`, `list`, `remove`, `use`, and `current` commands manage AI-Workspace **server connections** stored in the CLI config file (the same per-platform config used by `ap gateway` and `ap devportal`). The `build`, `push`, and `edit` commands are different: they work inside an **API project**. `build` **validates** the project's artifact; `push`/`edit` **generate** the creation payload from the project's artifacts and create/update the artifact on the server (the endpoint is chosen by the artifact kind). ## Prerequisites @@ -132,62 +132,58 @@ ap ai-workspace current ## `ap ai-workspace build` -Generates an **LLM proxy**, **LLM provider**, or **MCP proxy** creation payload (JSON) from an API project's artifacts. The payload shape is selected by the `kind` declared in `metadata.yaml`/`runtime.yaml`. In `metadata.yaml` the kind carries a `Metadata` suffix (the ai-workspace metadata kind); `runtime.yaml` uses the bare kind: +**Validates** the project's AI workspace artifact. Validation confirms that the configured files are present, that the metadata and runtime **kinds align**, and that the resource **name matches** between them. -- `kind: LlmProxyMetadata` (metadata.yaml) / `LlmProxy` (runtime.yaml) → matches the `POST /llm-proxies` request body (`LLMProxy` schema). -- `kind: LlmProviderMetadata` (metadata.yaml) / `LlmProvider` (runtime.yaml) → matches the `POST /llm-providers` request body (`LLMProvider` schema). -- `kind: McpMetadata` (metadata.yaml) / `Mcp` (runtime.yaml) → MCP proxy creation payload (capabilities + policies). +The kind is declared in `metadata.yaml`/`runtime.yaml`. In `metadata.yaml` the kind carries a `Metadata` suffix (the ai-workspace metadata kind); `runtime.yaml` uses the bare kind: + +- `kind: LlmProxyMetadata` (metadata.yaml) / `LlmProxy` (runtime.yaml) +- `kind: LlmProviderMetadata` (metadata.yaml) / `LlmProvider` (runtime.yaml) +- `kind: McpMetadata` (metadata.yaml) / `Mcp` (runtime.yaml) Any other kind is rejected. The `Metadata` suffix is stripped before the metadata and runtime kinds are matched, so the two files still refer to the same artifact (e.g. `LlmProxyMetadata` in metadata.yaml matches `LlmProxy` in runtime.yaml). ```shell -ap ai-workspace build [-f ] [-o ] +ap ai-workspace build [-f ] ``` Examples: ```shell -# Build using the current directory as the project root +# Validate using the current directory as the project root ap ai-workspace build -# Build from a specific project directory +# Validate a specific project directory ap ai-workspace build -f /path/to/project - -# Write the payload to a specific directory -ap ai-workspace build -o build/ - -# Write the payload to a specific file -ap ai-workspace build -o build/openai.json ``` ### What it reads -The command expects an API project containing `.api-platform/config.yaml`, and reads the `ai-workspaces` section of that file: +The command expects an API project containing `.api-platform/config.yaml`, and reads the `ai-workspace` section of that file. Unlike `devportals` (a list), a project has **at most one** `ai-workspace` configuration, as one project can have only one AI-Workspace: ```yaml -ai-workspaces: - - name: dev - portalRoot: . - filePaths: # paths relative to portalRoot - metadata: ./metadata.yaml - runtime: ./runtime.yaml - definition: ./definition.yaml # OpenAPI spec, required for all kinds +ai-workspace: + name: dev + portalRoot: . + filePaths: # paths relative to portalRoot + metadata: ./metadata.yaml + runtime: ./runtime.yaml + definition: ./definition.yaml # OpenAPI spec, required for all kinds ``` -For each configured entry, the build: +Validation (the same checks `push` and `edit` run before sending): -- Resolves `metadata`, `runtime`, and `definition` relative to that entry's `portalRoot` (defaults: `./metadata.yaml`, `./runtime.yaml`, `./definition.yaml`; `portalRoot` defaults to `.`, the project root). +- Resolves `metadata`, `runtime`, and `definition` relative to the entry's `portalRoot` (defaults: `./metadata.yaml`, `./runtime.yaml`, `./definition.yaml`; `portalRoot` defaults to `.`, the project root). - Requires `metadata.yaml` and `runtime.yaml` to exist. -- Requires the `kind` declared in `metadata.yaml` and `runtime.yaml` to match once the metadata's `Metadata` suffix is stripped (e.g. `LlmProxyMetadata` vs `LlmProxy`); otherwise the build fails with a kind-mismatch error. -- Requires `metadata.name` to match between `metadata.yaml` and `runtime.yaml`; otherwise the build fails with a name-mismatch error. +- Requires the `kind` declared in `metadata.yaml` and `runtime.yaml` to match once the metadata's `Metadata` suffix is stripped (e.g. `LlmProxyMetadata` vs `LlmProxy`); otherwise validation fails with a kind-mismatch error. +- Requires `metadata.name` to match between `metadata.yaml` and `runtime.yaml`; otherwise validation fails with a name-mismatch error. - Requires `definition.yaml` (the OpenAPI spec) for every kind — see [The OpenAPI spec](#the-openapi-spec) below. -- If no `ai-workspaces` section exists, a single `default` entry (`portalRoot: .`) is created in the project config and used. +- If no `ai-workspace` section exists, a single `default` entry (`portalRoot: .`) is created in the project config and used. -All resolved paths are constrained to the project directory; a path that escapes the project root fails the build for that entry. +All resolved paths are constrained to the project directory; a path that escapes the project root fails validation. #### Associating gateways (`metadata.yaml`) -Optionally list the gateways the artifact can be deployed to, with per-gateway configuration overrides, in an `associatedGateways` section **under `spec`** in `metadata.yaml`. This applies to all artifact kinds (`LlmProxyMetadata`, `LlmProviderMetadata`, `McpMetadata`). Each entry is keyed by the gateway `id`. The build extracts this list from `spec.associatedGateways` and copies it into the generated payload verbatim (entries without an `id` are dropped; the field is omitted entirely when absent): +Optionally list the gateways the artifact can be deployed to, with per-gateway configuration overrides, in an `associatedGateways` section **under `spec`** in `metadata.yaml`. This applies to all artifact kinds (`LlmProxyMetadata`, `LlmProviderMetadata`, `McpMetadata`). Each entry is keyed by the gateway `id`. `push`/`edit` extract this list from `spec.associatedGateways` and copy it into the generated payload verbatim (entries without an `id` are dropped; the field is omitted entirely when absent): ```yaml # metadata.yaml @@ -205,9 +201,44 @@ spec: `configurations` is a free-form object — the supported keys depend on the artifact type. -### What it generates +## `ap ai-workspace push` / `ap ai-workspace edit` + +These commands run the same validation as `build`, then **generate** the creation payload from the project artifacts and send it to the AI workspace. `push` **creates** the artifact (`POST`); `edit` **updates** an existing one (`PUT /{resource}/{id}`, where `id` is `metadata.name`). Both live at the root of `ap ai-workspace` (not under a per-kind group) and select the endpoint from the artifact **kind**: + +| Kind | `push` endpoint | `edit` endpoint | +| --- | --- | --- | +| `LlmProvider` | `POST /llm-providers` | `PUT /llm-providers/{id}` | +| `LlmProxy` | `POST /llm-proxies` | `PUT /llm-proxies/{id}` | +| `Mcp` | `POST /mcp-proxies` | `PUT /mcp-proxies/{id}` | + +```shell +ap ai-workspace push [-f ] [--project-id ] [--display-name ] [--platform ] [--insecure] [-o json] +ap ai-workspace edit [-f ] [--project-id ] [--display-name ] [--platform ] [--insecure] [-o json] +``` + +Examples: + +```shell +# Create/update a provider (no project scoping) +ap ai-workspace push +ap ai-workspace edit + +# Create/update a proxy or MCP proxy (project-scoped) +ap ai-workspace push --project-id +ap ai-workspace edit --project-id +``` -One JSON file per configured AI-Workspace entry, written to the build output. +Notes: + +- `-f` is the **project directory** (defaults to the current directory), not a payload file — the payload is generated in-memory and never written to disk. +- `--project-id` is **required** for the `LlmProxy` and `Mcp` kinds (they are project-scoped) and is injected into the payload; providers are not project-scoped and ignore it. +- The organization is derived from the auth token, so there is **no `--org` flag**. The AI workspace connection and credentials resolve like the other commands (`--display-name`/`--platform` or the active workspace; see [Authentication](#authentication)). +- By default a structured result is printed (like `ap gateway apply`): `Status`, `Message`, `ID`, and — when known — `Organization`, `Project`, `Created At`, `Updated At`, and `State`. `Project` shows the `--project-id` you supplied (proxies/MCP); `Organization` is derived from the auth token so it only appears when the server echoes `organizationId`. Pass `--output json` (or `-o json`) to print the full server response instead (useful for piping to `jq`). +- `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. + +### Generated payload + +The payload shape is selected by kind. #### `LlmProxy` @@ -287,26 +318,12 @@ A limit whose path is `/*` is applied as a `global` limit for its scope; a limit ### The OpenAPI spec -`definition.yaml` is **required for every kind** — the build errors if it is missing: +`definition.yaml` is **required for every kind** — validation errors if it is missing: - **`LlmProvider`** — folded into `openapi`. - **`LlmProxy`** — folded into `openapi`. - **`Mcp`** — its `prompts`/`resources`/`tools` populate `capabilities`. -### Output location and artifact names (`-o`) - -The artifact file name is taken from the **ai-workspace config `name`** in `.api-platform/config.yaml` (not from `metadata.name`). - -- **No `-o`** — payloads are written to the project `build/` directory as `build/.json`. -- **`-o `** (a path without a `.json` extension, e.g. `-o build/`) — payloads are written to that directory as `/.json`. Missing directories are created. -- **`-o `** (a path ending in `.json`) — the single payload is written to exactly that file. Missing parent directories are created. - -Behavior notes: - -- An existing output file is overwritten. -- A `.json` file target can only hold one payload, so `-o ` with **multiple** `ai-workspaces` configurations is an error — use a directory instead. -- With a directory target and multiple configurations, one file per config is produced (`.json`). - ## Get Commands These commands retrieve artifacts from the AI workspace resolved from the CLI config (`--display-name`/`--platform`, or the active AI workspace). With `--id` a single artifact is fetched; without it all artifacts are listed, with optional `--limit`/`--offset` pagination. The full JSON response is printed. @@ -407,94 +424,6 @@ Notes: - `--id` is required for all delete commands. - `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. -## Push Commands - -These commands push a payload JSON (produced by `ap ai-workspace build`) to the AI workspace server resolved from the CLI config (`--display-name`/`--platform`, or the active AI workspace). The organization is derived from the auth token, so **no `--org` flag is needed**. Credentials come from the configured auth type (see [Authentication](#authentication)). - -### `ap ai-workspace llm-provider push` - -Creates an LLM provider with `POST /api/v0.9/llm-providers` (operationId `createLLMProvider`). The JSON file is sent as the request body unchanged. - -```shell -ap ai-workspace llm-provider push -f [--display-name ] [--platform ] [--insecure] -``` - -Examples: - -```shell -ap ai-workspace llm-provider push -f build/wso2-claude.json -ap ai-workspace llm-provider push -f build/wso2-claude.json --display-name my-workspace --platform eu -``` - -### `ap ai-workspace app-llm-proxy push` - -Creates an LLM proxy with `POST /api/v0.9/llm-proxies` (operationId `createLLMProxy`). The supplied `--project-id` is injected into the payload as `projectId` before it is sent. - -```shell -ap ai-workspace app-llm-proxy push -f --project-id [--display-name ] [--platform ] [--insecure] -``` - -Examples: - -```shell -ap ai-workspace app-llm-proxy push -f build/wso2-openai-proxy.json --project-id 550e8400-e29b-41d4-a716-446655440000 -``` - -### `ap ai-workspace mcp-proxy push` - -Creates an MCP proxy with `POST /api/v0.9/mcp-proxies` (operationId `createMCPProxy`). Like the LLM proxy, the supplied `--project-id` is injected into the payload as `projectId` before it is sent. - -```shell -ap ai-workspace mcp-proxy push -f --project-id [--display-name ] [--platform ] [--insecure] -``` - -Examples: - -```shell -ap ai-workspace mcp-proxy push -f build/bijira-mcp-everything.json --project-id 019ecf0e-8237-7153-96d5-bb3934e2c313 -``` - -Notes: - -- `--file` is required for all push commands; `--project-id` is also required for LLM proxies and MCP proxies. The organization is derived from the auth token, so no `--org` flag is needed. -- By default a structured result is printed (like `ap gateway apply`): `Status`, `Message`, `ID`, and — when known — `Organization`, `Project`, `Created At`, `Updated At`, and `State`. `Project` shows the `--project-id` you supplied (proxies/MCP); `Organization` is derived from the auth token so it only appears when the server echoes `organizationId`. Pass `--output json` (or `-o json`) to print the full server response instead (useful for piping to `jq`). -- `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. - -## Edit Commands - -These commands update an existing artifact on the AI workspace by sending its payload JSON with a `PUT` request to the id-scoped resource path (`/{resource}/{id}`). The resource `id` is taken from the payload's `id` field, the organization is derived from the auth token (**no `--org` flag**), and the AI workspace and credentials are resolved exactly like the push commands. - -### `ap ai-workspace llm-provider edit` - -Updates an existing LLM provider with `PUT /api/v0.9/llm-providers/{id}` (operationId `updateLLMProvider`). The JSON file is sent as the request body unchanged. - -```shell -ap ai-workspace llm-provider edit -f [--display-name ] [--platform ] [--insecure] -``` - -### `ap ai-workspace app-llm-proxy edit` - -Updates an existing LLM proxy with `PUT /api/v0.9/llm-proxies/{id}` (operationId `updateLLMProxy`). The supplied `--project-id` is injected into the payload as `projectId` before it is sent. - -```shell -ap ai-workspace app-llm-proxy edit -f --project-id [--display-name ] [--platform ] [--insecure] -``` - -### `ap ai-workspace mcp-proxy edit` - -Updates an existing MCP proxy with `PUT /api/v0.9/mcp-proxies/{id}` (operationId `updateMCPProxy`). Like the LLM proxy, the supplied `--project-id` is injected into the payload as `projectId` before it is sent. - -```shell -ap ai-workspace mcp-proxy edit -f --project-id [--display-name ] [--platform ] [--insecure] -``` - -Notes: - -- `--file` is required for all edit commands; `--project-id` is also required for LLM proxies and MCP proxies. The organization is derived from the auth token, so no `--org` flag is needed. -- The payload must contain the `id` of the artifact to update; it identifies the resource in the request URL. -- By default a structured result is printed (like `ap gateway apply`): `Status`, `Message`, `ID`, and — when known — `Organization`, `Project`, `Created At`, `Updated At`, and `State`. `Project` shows the `--project-id` you supplied (proxies/MCP); `Organization` is derived from the auth token so it only appears when the server echoes `organizationId`. Pass `--output json` (or `-o json`) to print the full server response instead (useful for piping to `jq`). -- `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. - ## Related Commands - `ap platform add` diff --git a/docs/cli/end-to-end-workflow.md b/docs/cli/end-to-end-workflow.md index caf08c5ca2..5020c43410 100644 --- a/docs/cli/end-to-end-workflow.md +++ b/docs/cli/end-to-end-workflow.md @@ -24,7 +24,7 @@ flowchart LR subgraph AW["AI Workspace"] direction LR - I["3b · Build
ap ai-workspace build"] --> J["4b · Push
ap ai-workspace app-llm-proxy push"] + I["3b · Validate
ap ai-workspace build"] --> J["4b · Push
ap ai-workspace push"] end E -->|REST API| F @@ -89,18 +89,19 @@ ap devportal rest-api publish -f build/devportal.zip --org #### AI Workspace ```shell -ap ai-workspace build # → build/.json -ap ai-workspace app-llm-proxy push -f build/.json --project-id -# the same pattern applies to: ap ai-workspace llm-provider push / ap ai-workspace mcp-proxy push -# (the organization comes from the auth token — no --org flag) +ap ai-workspace build # validate the project's artifact +ap ai-workspace push --project-id # generate the payload and create the artifact +# (--project-id is required for LlmProxy/Mcp kinds, not for LlmProvider) +# to update an existing artifact instead of creating: ap ai-workspace edit --project-id +# the endpoint is chosen by the artifact kind; the organization comes from the auth token — no --org flag ``` -`ap ai-workspace build` reads each ai-workspace entry in `.api-platform/config.yaml` and generates a creation payload (JSON), folding the OpenAPI spec from `definition.yaml` into the payload. +`ap ai-workspace build` reads the ai-workspace entry in `.api-platform/config.yaml` and **validates** the artifact (files present, metadata/runtime kinds align, name matches). `ap ai-workspace push`/`edit` run the same validation, then generate the creation payload (folding the OpenAPI spec from `definition.yaml` into it) and create/update the artifact on the server. ## Notes -- `ap devportal gen`, `ap devportal build`, and `ap ai-workspace build` all operate on an API project (they require `.api-platform/config.yaml`). -- Developer Portal is two stages: `gen` **generates** the editable artifact source under `./devportal`, then `build` **packages** it into `build/devportal.zip`. AI Workspace's `build` generates the JSON payload directly (no separate `gen`). -- Both `build` commands write into the project's `build/` directory, one artifact per configured portal entry. +- `ap devportal gen`, `ap devportal build`, and `ap ai-workspace build`/`push`/`edit` all operate on an API project (they require `.api-platform/config.yaml`). +- Developer Portal is two stages: `gen` **generates** the editable artifact source under `./devportal`, then `build` **packages** it into `build/devportal.zip`. +- AI Workspace's `build` only **validates** — it writes nothing. The creation payload is generated in-memory by `push`/`edit` at publish time (no build artifact is written to `build/`). - `--org` on the publish/push commands is the target organization in the Developer Portal / AI Workspace. - Add `--insecure` to any portal/gateway command when talking to a local or self-signed HTTPS endpoint. From 3fac0968803338a676531de7275ee7d395fec79e Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Mon, 6 Jul 2026 12:05:47 +0530 Subject: [PATCH 24/27] introduce env variable injection for push --- cli/src/cmd/aiws/build.go | 129 +++++++++++++++++++++++++++ cli/src/cmd/aiws/build_test.go | 153 +++++++++++++++++++++++++++++++- cli/src/cmd/aiws/edit.go | 9 ++ cli/src/cmd/aiws/push.go | 12 +++ cli/src/utils/flags.go | 1 + docs/cli/ai-workspace/README.md | 30 ++++++- 6 files changed, 331 insertions(+), 3 deletions(-) diff --git a/cli/src/cmd/aiws/build.go b/cli/src/cmd/aiws/build.go index 3e39d1c8e1..606f466440 100644 --- a/cli/src/cmd/aiws/build.go +++ b/cli/src/cmd/aiws/build.go @@ -23,6 +23,8 @@ import ( "fmt" "os" "path/filepath" + "regexp" + "sort" "strconv" "strings" @@ -313,6 +315,133 @@ func marshalAIWorkspacePayload(artifact *aiWorkspaceArtifact, projectIDFlag stri return body, projectID, nil } +// cliEnvPlaceholderPattern matches ENV_CLI_* placeholders carried from +// metadata.yaml/runtime.yaml into the generated payload. Both `${ENV_CLI_X}` +// and `$ENV_CLI_X` forms are supported, as well as a bare `ENV_CLI_X` token. +// The bare form requires a leading word boundary so a placeholder embedded in a +// larger identifier (e.g. MY_ENV_CLI_X) is not partially substituted. +var cliEnvPlaceholderPattern = regexp.MustCompile(`\$\{ENV_CLI_[A-Za-z0-9_]+\}|\$ENV_CLI_[A-Za-z0-9_]+|\bENV_CLI_[A-Za-z0-9_]+`) + +// cliEnvPlaceholderName strips the `${...}`/`$` wrapping from a matched +// placeholder, returning the bare variable name (ENV_CLI_X). +func cliEnvPlaceholderName(placeholder string) string { + name := strings.TrimPrefix(placeholder, "${") + name = strings.TrimSuffix(name, "}") + return strings.TrimPrefix(name, "$") +} + +// resolveEnvPlaceholders substitutes ENV_CLI_* placeholders in the generated +// payload before it is sent to the AI workspace. Values are looked up in an +// env file first — the file given via --env-file when provided (it must +// exist), otherwise the project root's .env when present — falling back to the +// process environment for names the file does not define. When the payload has +// no placeholders it is returned unchanged. Any placeholder that cannot be +// resolved fails the command with the full list of missing variables. +func resolveEnvPlaceholders(body []byte, projectRoot, envFileFlag string) ([]byte, error) { + content := string(body) + matches := cliEnvPlaceholderPattern.FindAllString(content, -1) + if len(matches) == 0 { + return body, nil + } + + fileValues, err := loadEnvValues(projectRoot, envFileFlag) + if err != nil { + return nil, err + } + + missing := map[string]bool{} + resolved := cliEnvPlaceholderPattern.ReplaceAllStringFunc(content, func(placeholder string) string { + name := cliEnvPlaceholderName(placeholder) + value, ok := fileValues[name] + if !ok { + value, ok = os.LookupEnv(name) + } + if !ok { + missing[name] = true + return placeholder + } + // The replacement happens inside JSON string values, so the value must be + // JSON-escaped (sans the surrounding quotes json.Marshal adds). + escaped, _ := json.Marshal(value) + return string(escaped[1 : len(escaped)-1]) + }) + + if len(missing) > 0 { + names := make([]string, 0, len(missing)) + for name := range missing { + names = append(names, name) + } + sort.Strings(names) + return nil, fmt.Errorf("unresolved environment variable(s) in the generated artifact: %s (define them in an env file passed via --%s, a .env file in the project root, or the environment)", + strings.Join(names, ", "), utils.FlagEnvFile) + } + + return []byte(resolved), nil +} + +// loadEnvValues returns the KEY=VALUE pairs from the env file selected for +// placeholder resolution: the --env-file path when given (missing file is an +// error), else the project root's .env when it exists, else an empty map (the +// process environment is consulted by the caller as the fallback). +func loadEnvValues(projectRoot, envFileFlag string) (map[string]string, error) { + if path := strings.TrimSpace(envFileFlag); path != "" { + values, err := parseEnvFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read env file %q: %w", path, err) + } + return values, nil + } + + defaultPath := filepath.Join(projectRoot, ".env") + if _, err := os.Stat(defaultPath); err != nil { + if os.IsNotExist(err) { + return map[string]string{}, nil + } + return nil, fmt.Errorf("failed to inspect .env file: %w", err) + } + values, err := parseEnvFile(defaultPath) + if err != nil { + return nil, fmt.Errorf("failed to read .env file %q: %w", defaultPath, err) + } + return values, nil +} + +// parseEnvFile reads a dotenv-style file: one KEY=VALUE per line, blank lines +// and #-comments ignored, an optional `export ` prefix allowed, and single or +// double quotes around the value stripped. +func parseEnvFile(path string) (map[string]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + values := map[string]string{} + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + line = strings.TrimPrefix(line, "export ") + key, value, found := strings.Cut(line, "=") + if !found { + continue + } + key = strings.TrimSpace(key) + if key == "" { + continue + } + value = strings.TrimSpace(value) + if len(value) >= 2 { + if (strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`)) || + (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) { + value = value[1 : len(value)-1] + } + } + values[key] = value + } + return values, nil +} + // aiWorkspaceCreatePath returns the collection endpoint to POST to for the kind. func aiWorkspaceCreatePath(baseKind string) string { switch baseKind { diff --git a/cli/src/cmd/aiws/build_test.go b/cli/src/cmd/aiws/build_test.go index cc8a21850a..b37f78aeaf 100644 --- a/cli/src/cmd/aiws/build_test.go +++ b/cli/src/cmd/aiws/build_test.go @@ -18,7 +18,13 @@ package aiws -import "testing" +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) func newProxyRuntime() aiWorkspaceRuntime { var rt aiWorkspaceRuntime @@ -182,3 +188,148 @@ func TestBuildLLMProviderPayload_OmitsModelProvidersForUnknownTemplate(t *testin t.Fatalf("expected no modelProviders for unknown template, got %#v", payload.ModelProviders) } } + +func writeTestEnvFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write env file: %v", err) + } + return path +} + +func TestResolveEnvPlaceholders_AllFormsFromProjectDotEnv(t *testing.T) { + root := t.TempDir() + writeTestEnvFile(t, root, ".env", "ENV_CLI_A=alpha\nENV_CLI_B=beta\nENV_CLI_C=gamma\n") + + body := []byte(`{"a":"${ENV_CLI_A}","b":"$ENV_CLI_B","c":"ENV_CLI_C"}`) + got, err := resolveEnvPlaceholders(body, root, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := `{"a":"alpha","b":"beta","c":"gamma"}` + if string(got) != want { + t.Fatalf("got %s, want %s", got, want) + } +} + +func TestResolveEnvPlaceholders_ExplicitFileWinsProcessEnvFillsGaps(t *testing.T) { + root := t.TempDir() + // A .env in the project root must be ignored when --env-file is given. + writeTestEnvFile(t, root, ".env", "ENV_CLI_KEY=from-dotenv\n") + explicit := writeTestEnvFile(t, root, "custom.env", "ENV_CLI_KEY=from-file\n") + + t.Setenv("ENV_CLI_KEY", "from-process") + t.Setenv("ENV_CLI_ONLY_PROCESS", "process-value") + + body := []byte(`{"k":"${ENV_CLI_KEY}","p":"${ENV_CLI_ONLY_PROCESS}"}`) + got, err := resolveEnvPlaceholders(body, root, explicit) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := `{"k":"from-file","p":"process-value"}` + if string(got) != want { + t.Fatalf("got %s, want %s", got, want) + } +} + +func TestResolveEnvPlaceholders_MissingVariablesError(t *testing.T) { + root := t.TempDir() + body := []byte(`{"a":"${ENV_CLI_MISSING_ONE}","b":"$ENV_CLI_MISSING_TWO"}`) + _, err := resolveEnvPlaceholders(body, root, "") + if err == nil { + t.Fatal("expected an error for unresolved placeholders") + } + for _, name := range []string{"ENV_CLI_MISSING_ONE", "ENV_CLI_MISSING_TWO"} { + if !strings.Contains(err.Error(), name) { + t.Fatalf("error should name %s: %v", name, err) + } + } +} + +func TestResolveEnvPlaceholders_NoPlaceholdersUnchangedAndNoEnvNeeded(t *testing.T) { + root := t.TempDir() + body := []byte(`{"a":"plain"}`) + got, err := resolveEnvPlaceholders(body, root, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != string(body) { + t.Fatalf("body changed: %s", got) + } +} + +func TestResolveEnvPlaceholders_JSONEscapesValues(t *testing.T) { + root := t.TempDir() + writeTestEnvFile(t, root, ".env", `ENV_CLI_SECRET=va"l\ue`+"\n") + + body := []byte(`{"s":"${ENV_CLI_SECRET}"}`) + got, err := resolveEnvPlaceholders(body, root, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var decoded map[string]string + if err := json.Unmarshal(got, &decoded); err != nil { + t.Fatalf("resolved payload is not valid JSON: %v (%s)", err, got) + } + if decoded["s"] != `va"l\ue` { + t.Fatalf("got %q, want %q", decoded["s"], `va"l\ue`) + } +} + +func TestResolveEnvPlaceholders_ExplicitEnvFileMissingIsError(t *testing.T) { + root := t.TempDir() + body := []byte(`{"a":"${ENV_CLI_A}"}`) + if _, err := resolveEnvPlaceholders(body, root, filepath.Join(root, "nope.env")); err == nil { + t.Fatal("expected an error for a missing --env-file") + } +} + +func TestParseEnvFile_CommentsExportAndQuotes(t *testing.T) { + root := t.TempDir() + path := writeTestEnvFile(t, root, "vals.env", strings.Join([]string{ + "# comment", + "", + "export ENV_CLI_EXPORTED=one", + `ENV_CLI_DOUBLE="two words"`, + "ENV_CLI_SINGLE='three'", + "ENV_CLI_EQ=a=b", + "not-a-pair", + }, "\n")) + + values, err := parseEnvFile(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := map[string]string{ + "ENV_CLI_EXPORTED": "one", + "ENV_CLI_DOUBLE": "two words", + "ENV_CLI_SINGLE": "three", + "ENV_CLI_EQ": "a=b", + } + for k, v := range want { + if values[k] != v { + t.Fatalf("%s: got %q, want %q", k, values[k], v) + } + } + if len(values) != len(want) { + t.Fatalf("unexpected extra entries: %#v", values) + } +} + +func TestResolveEnvPlaceholders_BareFormRequiresWordBoundary(t *testing.T) { + root := t.TempDir() + writeTestEnvFile(t, root, ".env", "ENV_CLI_FOO=resolved\n") + + // A bare placeholder at a boundary resolves; the same token embedded in a + // larger identifier (MY_ENV_CLI_FOO) must be left untouched. + body := []byte(`{"a":"ENV_CLI_FOO","b":"MY_ENV_CLI_FOO"}`) + got, err := resolveEnvPlaceholders(body, root, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := `{"a":"resolved","b":"MY_ENV_CLI_FOO"}` + if string(got) != want { + t.Fatalf("got %s, want %s", got, want) + } +} diff --git a/cli/src/cmd/aiws/edit.go b/cli/src/cmd/aiws/edit.go index 9a0a4550fe..5cb7cc0996 100644 --- a/cli/src/cmd/aiws/edit.go +++ b/cli/src/cmd/aiws/edit.go @@ -43,6 +43,7 @@ ap ai-workspace edit -f /path/to/project --project-id --display-nam var ( editProjectDir string editProjectID string + editEnvFile string editName string editPlatform string editInsecure bool @@ -68,6 +69,7 @@ var editCmd = &cobra.Command{ func init() { utils.AddStringFlag(editCmd, utils.FlagFile, &editProjectDir, "", "Path to the project directory (defaults to current directory)") utils.AddStringFlag(editCmd, utils.FlagProjectID, &editProjectID, "", "Project ID (required for LlmProxy and Mcp kinds)") + utils.AddStringFlag(editCmd, utils.FlagEnvFile, &editEnvFile, "", "Path to an env file resolving ENV_CLI_* placeholders (defaults to .env in the project root)") utils.AddStringFlag(editCmd, utils.FlagName, &editName, "", "AI workspace display name") utils.AddStringFlag(editCmd, utils.FlagPlatform, &editPlatform, "", "Platform name") utils.AddStringFlag(editCmd, utils.FlagOutput, &editOutput, "", "Output format: \"json\" prints the full server response (default: summary)") @@ -90,6 +92,13 @@ func runEditCommand() error { return err } + // Resolve ENV_CLI_* placeholders carried from metadata.yaml/runtime.yaml + // into the generated payload before it is sent. + body, err = resolveEnvPlaceholders(body, projectRoot, editEnvFile) + if err != nil { + return err + } + cfg, err := config.LoadConfig() if err != nil { return fmt.Errorf("failed to load config: %w", err) diff --git a/cli/src/cmd/aiws/push.go b/cli/src/cmd/aiws/push.go index 1af405950c..ceb4bf6a48 100644 --- a/cli/src/cmd/aiws/push.go +++ b/cli/src/cmd/aiws/push.go @@ -36,6 +36,9 @@ ap ai-workspace push # Push a proxy or MCP artifact (--project-id is required for those kinds) ap ai-workspace push --project-id +# Resolve ENV_CLI_* placeholders from a specific env file (defaults to .env in the project root) +ap ai-workspace push --env-file ./secrets.env + # Push from a specific project directory using a specific AI workspace ap ai-workspace push -f /path/to/project --project-id --display-name my-workspace --platform eu` ) @@ -43,6 +46,7 @@ ap ai-workspace push -f /path/to/project --project-id --display-nam var ( pushProjectDir string pushProjectID string + pushEnvFile string pushName string pushPlatform string pushInsecure bool @@ -68,6 +72,7 @@ var pushCmd = &cobra.Command{ func init() { utils.AddStringFlag(pushCmd, utils.FlagFile, &pushProjectDir, "", "Path to the project directory (defaults to current directory)") utils.AddStringFlag(pushCmd, utils.FlagProjectID, &pushProjectID, "", "Project ID (required for LlmProxy and Mcp kinds)") + utils.AddStringFlag(pushCmd, utils.FlagEnvFile, &pushEnvFile, "", "Path to an env file resolving ENV_CLI_* placeholders (defaults to .env in the project root)") utils.AddStringFlag(pushCmd, utils.FlagName, &pushName, "", "AI workspace display name") utils.AddStringFlag(pushCmd, utils.FlagPlatform, &pushPlatform, "", "Platform name") utils.AddStringFlag(pushCmd, utils.FlagOutput, &pushOutput, "", "Output format: \"json\" prints the full server response (default: summary)") @@ -90,6 +95,13 @@ func runPushCommand() error { return err } + // Resolve ENV_CLI_* placeholders carried from metadata.yaml/runtime.yaml + // into the generated payload before it is sent. + body, err = resolveEnvPlaceholders(body, projectRoot, pushEnvFile) + if err != nil { + return err + } + cfg, err := config.LoadConfig() if err != nil { return fmt.Errorf("failed to load config: %w", err) diff --git a/cli/src/utils/flags.go b/cli/src/utils/flags.go index f793c0ca25..6a675e1998 100644 --- a/cli/src/utils/flags.go +++ b/cli/src/utils/flags.go @@ -84,6 +84,7 @@ const ( FlagReferenceID = "reference-id" FlagGatewayType = "gateway-type" FlagProjectID = "project-id" + FlagEnvFile = "env-file" ) var shortFlags = map[string]string{ diff --git a/docs/cli/ai-workspace/README.md b/docs/cli/ai-workspace/README.md index 7b2e786a4b..d7a0b1d2e9 100644 --- a/docs/cli/ai-workspace/README.md +++ b/docs/cli/ai-workspace/README.md @@ -212,8 +212,8 @@ These commands run the same validation as `build`, then **generate** the creatio | `Mcp` | `POST /mcp-proxies` | `PUT /mcp-proxies/{id}` | ```shell -ap ai-workspace push [-f ] [--project-id ] [--display-name ] [--platform ] [--insecure] [-o json] -ap ai-workspace edit [-f ] [--project-id ] [--display-name ] [--platform ] [--insecure] [-o json] +ap ai-workspace push [-f ] [--project-id ] [--env-file ] [--display-name ] [--platform ] [--insecure] [-o json] +ap ai-workspace edit [-f ] [--project-id ] [--env-file ] [--display-name ] [--platform ] [--insecure] [-o json] ``` Examples: @@ -226,6 +226,9 @@ ap ai-workspace edit # Create/update a proxy or MCP proxy (project-scoped) ap ai-workspace push --project-id ap ai-workspace edit --project-id + +# Resolve ENV_CLI_* placeholders from a specific env file +ap ai-workspace push --env-file ./secrets.env ``` Notes: @@ -236,6 +239,29 @@ Notes: - By default a structured result is printed (like `ap gateway apply`): `Status`, `Message`, `ID`, and — when known — `Organization`, `Project`, `Created At`, `Updated At`, and `State`. `Project` shows the `--project-id` you supplied (proxies/MCP); `Organization` is derived from the auth token so it only appears when the server echoes `organizationId`. Pass `--output json` (or `-o json`) to print the full server response instead (useful for piping to `jq`). - `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. +### Environment variable placeholders (`ENV_CLI_*`) + +`metadata.yaml` and `runtime.yaml` may reference **environment variables** for specific field values using the `ENV_CLI_` prefix. This lets you keep values that change between environments — upstream URLs, hosts, project IDs, model names, etc. — out of the project files and supply them at push time. Supported forms: `${ENV_CLI_NAME}`, `$ENV_CLI_NAME`, or a bare `ENV_CLI_NAME` token. + +> **This is for environment-specific configuration values, not secrets.** The resolved value is substituted into the artifact and sent to the server (it travels in the request body and is stored in the created artifact). Do **not** use it for API keys, tokens, or other secrets. Secrets should be managed server-side and referenced with the platform's `{{ secret "name" }}` placeholders (resolved by the server from its encrypted secret store), which the CLI leaves untouched. + +```yaml +# runtime.yaml — a per-environment upstream URL supplied at push time +spec: + upstream: + url: ${ENV_CLI_UPSTREAM_URL} +``` + +`push`/`edit` resolve the placeholders in the **generated payload** just before it is sent, looking up each variable in this order: + +1. **`--env-file `** — when given, values come from that env file (a missing file is an error). +2. **`.env` in the project root** — used by default when `--env-file` is not given. +3. **Process environment** — fills any names the selected file does not define (and is the sole source when neither file exists). + +The env file format is one `KEY=VALUE` per line; blank lines and `#` comments are ignored, an `export ` prefix is allowed, and single/double quotes around the value are stripped. + +**Push/edit fail if a referenced variable has no value** at push time — the command errors and names every unresolved variable, and nothing is sent to the server. Define the missing variables in an env file (`--env-file` or the project's `.env`) or in the environment and retry. `build` does not resolve placeholders — it only validates the project files. + ### Generated payload The payload shape is selected by kind. From 08e61061c112ad87bc5873d211a31cbf4696b262 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Mon, 6 Jul 2026 14:55:15 +0530 Subject: [PATCH 25/27] address comments --- .agents/skills/sync-cli-with-openapi/SKILL.md | 154 ++++++++++++++++++ .../references/cli-command-anatomy.md | 4 +- .vscode/launch.json | 19 +-- cli/src/cmd/aiws/build.go | 2 +- cli/src/cmd/devportal/apikey/generate.go | 4 +- cli/src/cmd/devportal/build.go | 34 ++-- cli/src/cmd/devportal/build_test.go | 57 ++++++- cli/src/cmd/devportal/commands_test.go | 1 - cli/src/cmd/devportal/gen.go | 49 +++++- cli/src/cmd/devportal/gen_test.go | 61 +++++++ cli/src/cmd/devportal/org/delete.go | 1 - .../cmd/devportal/restapi/commands_test.go | 1 - cli/src/cmd/devportal/subscription/delete.go | 1 - cli/src/cmd/devportal/subscription/get.go | 1 - cli/src/cmd/project/init.go | 1 - 15 files changed, 333 insertions(+), 57 deletions(-) create mode 100644 .agents/skills/sync-cli-with-openapi/SKILL.md diff --git a/.agents/skills/sync-cli-with-openapi/SKILL.md b/.agents/skills/sync-cli-with-openapi/SKILL.md new file mode 100644 index 0000000000..1f50c425c9 --- /dev/null +++ b/.agents/skills/sync-cli-with-openapi/SKILL.md @@ -0,0 +1,154 @@ +--- +name: sync-cli-with-openapi +description: Synchronize the `ap` CLI (cli/) with a REST API OpenAPI spec after the spec changes. Use when someone edits platform-api/src/resources/openapi.yaml (consumed by the `ap ai-ws` family via its LLM/MCP endpoints), portals/developer-portal/docs/devportal-openapi-spec-v1.yaml (the `ap devportal` family), or gateway/gateway-controller/api/management-openapi.yaml (the `ap gateway` family) and the matching CLI commands need updating — a changed request/response/parameter on an endpoint an existing command calls, OR a newly added endpoint that needs a brand-new command. Trigger on "update the CLI for this spec change", "the spec changed, sync the CLI commands", "add a CLI command for this new endpoint", or when naming this skill directly. Keeps commands, path constants/helpers, command registration, docs, and tests in lockstep with the spec. +allowed-tools: Bash, Read, Edit, Write, Grep, Glob +--- + +# Sync the `ap` CLI with an OpenAPI spec + +Keep the Go CLI under `cli/` in sync with a REST API contract after its OpenAPI spec changes. This covers two jobs: + +1. **Update existing commands** when an endpoint they already call changes shape (new/renamed parameter, changed request/response body, new required field, removed operation). +2. **Scaffold new commands** for endpoints newly added to a spec that have no CLI coverage yet. + +The linking pin is always the **HTTP path + method**: a spec operation maps to exactly one CLI call site. This skill works by discovering that call site (or its absence) and reconciling it — it does **not** assume a fixed spec→command table, because that drifts. + +## Invoking this skill + +There are two ways to drive it. Both run the same workflow — they differ only in *scope*. + +### Mode A — sync everything that changed in a spec (diff-driven) + +Use when a spec file changed (e.g. a `git pull` or merge brought in new/edited endpoints) and you want the CLI reconciled to match. You do **not** list the endpoints — the skill reads the diff and classifies every changed operation itself (Step 1). + +Trigger prompts: +- *"The devportal spec changed in the last pull — sync the CLI commands."* +- *"/sync-cli-with-openapi platform-api/src/resources/openapi.yaml"* + +**After a pull/merge the change is already committed**, so a plain `git diff` shows nothing — the skill must diff against the pre-change state (Step 1 lists the exact commands). If the user knows the base, they can say so: *"…diff it against `HEAD@{1}`"* or *"…against `main`"*. + +### Mode B — convert a named set of endpoints into commands (scope-driven) + +Use when you want CLI coverage for *specific* endpoints — regardless of what the diff says. You name the operations; the skill jumps straight to scaffolding them (Step 2 → Step 3, ADDED path) and ignores the rest. + +Trigger prompts: +- *"Add `ap` commands for `POST /devportal/v1/webhook-subscribers` and `GET /devportal/v1/webhook-subscribers/{subscriberId}`."* +- *"From platform-api/openapi.yaml, only wire the `llm-provider-templates` endpoints (list, get, copy) into the `ai-ws` CLI."* + +The skill still reads each named operation's real schema from the spec so flags/payloads/validation are correct — it just skips the diff-classification breadth. + +**In both modes**, if the user hasn't named the spec, infer it from the paths/family they mention and confirm. If a named endpoint has no consumer *and* no clear family (e.g. a non-LLM platform-api path), report it and ask before inventing a new command family. + +## Spec → CLI-family map + +Three specs feed the CLI. Each is consumed by a distinct command family with its own client and conventions: + +| Spec file | CLI family | Root dir | Client package | +|---|---|---|---| +| `gateway/gateway-controller/api/management-openapi.yaml` | `ap gateway ...` | `cli/src/cmd/gateway/` | `internal/gateway` | +| `portals/developer-portal/docs/devportal-openapi-spec-v1.yaml` | `ap devportal ...` | `cli/src/cmd/devportal/` | `internal/devportal` | +| `platform-api/src/resources/openapi.yaml` | `ap ai-ws ...` (partial — see note) | `cli/src/cmd/aiws/` | `internal/aiworkspace` | + +**Note on `platform-api/src/resources/openapi.yaml`:** the `ap ai-ws` (AI workspace) family consumes this spec, but **only its LLM/MCP subset** — `/llm-providers`, `/llm-proxies`, `/mcp-proxies` (and the `llm-provider-templates` endpoints). The CLI reaches them under the `/api-proxy/api/v0.9/` prefix via path constants in `cli/src/utils/constants.go` (e.g. `AIWorkspaceLLMProvidersPath = "/api-proxy/api/v0.9/llm-providers"`), so the spec path is *unversioned* (`/llm-providers`) but the CLI call is *versioned* — match on the resource segment, not the whole path. The spec's many other resources (`/organizations`, `/projects`, `/rest-apis`, `/gateways`, `/applications`, `/subscription-plans`, `/subscriptions`, …) have **no `ap` consumer today** — the `ap gateway` commands look similar but actually track the separate *gateway management* spec. So for a platform-api change: if it touches an LLM/MCP endpoint, sync the `ai-ws` family; otherwise discovery (Step 2) will find no consumer — say so and ask the user whether they want a new command family, rather than inventing edits. + +Full anatomy of a command, the exact helpers per family, and copy-ready templates live in **`references/cli-command-anatomy.md`**. Read that file before writing or editing any command — do not work from memory. + +## Workflow + +### Step 1 — Identify what changed in the spec + +**Mode B (named endpoints):** skip the diff — take the operations the user listed as the ADDED set and go to Step 2. Still open the spec file to read each operation's schema. + +**Mode A (diff-driven):** get the diff of the spec. Pick the command by where the change lives: + +```bash +git diff -- # uncommitted local edits (you're mid-editing the spec) +git diff HEAD@{1}..HEAD -- # change arrived via a pull (compare to pre-pull HEAD) +git diff main...HEAD -- # everything new on this branch vs main (PR review) +git diff .. -- +``` + +If none show a diff, the spec is unchanged relative to that base — confirm the base with the user (a pull needs `HEAD@{1}` or the merge commit, not a plain `git diff`). + +If the user hasn't said which spec, infer from the path they name and confirm it's one of the three above. From the diff, build a list of **changed operations**, each classified as: + +- **ADDED** — a new `path` or a new method under an existing path → candidate for a *new command*. +- **MODIFIED** — an existing operation whose parameters, `requestBody`, responses, required fields, or `operationId`/scopes changed → *update the existing command*. +- **REMOVED** — an operation deleted → *remove or deprecate the command*. + +For each operation record: HTTP method, full path (e.g. `POST /devportal/v1/subscriptions/{subId}/change-plan`), the changed parameters/fields, and the OAuth2 scope if the spec declares one. + +### Step 2 — Locate the CLI consumer (or confirm there is none) + +For each operation, find the call site. The path segment is the search key. Match against how each family stores paths: + +- **Gateway family** — paths are constants in `cli/src/utils/constants.go` (e.g. `GatewaySubscriptionPlansPath = "/subscription-plans"`, `%s` for IDs). Grep for the literal path, then for the constant name: + ```bash + grep -rn "subscription-plans" cli/src/utils/constants.go + grep -rn "GatewaySubscriptionPlansPath" cli/src/cmd/gateway/ + ``` +- **DevPortal family** — org-scoped paths are built with `internaldevportal.OrgScopedPath(orgID, "")`, which produces `/o/{orgId}/devportal/{APIVersion}/` (`APIVersion` = `v1`, in `internal/devportal/helpers.go`). Only org-management endpoints use the raw `/organizations...` path. Grep for the resource segment: + ```bash + grep -rn "OrgScopedPath\|\"/organizations" cli/src/cmd/devportal/ + ``` +- **AI-workspace family** — paths are constants in `cli/src/utils/constants.go` (`AIWorkspaceLLMProvidersPath` etc., under `/api-proxy/api/v0.9/`), wrapped by helpers in `cli/src/internal/aiworkspace/helpers.go` (`ProviderPath(orgID)`, `ProviderResourcePath(orgID,id)`, `ProviderListPath(orgID,q)`, and the `Proxy*`/`MCPProxy*` equivalents). Org/project scoping is a **query parameter** (`?organizationId=`/`?projectId=`), not a path segment. Match on the resource segment, since the spec path is unversioned: + ```bash + grep -rn "llm-providers\|llm-proxies\|mcp-proxies" cli/src/utils/constants.go + grep -rn "AIWorkspaceLLMProvidersPath\|ProviderPath\|ProviderResourcePath" cli/src/cmd/aiws/ cli/src/internal/aiworkspace/ + ``` + +Outcomes: +- **Found** → MODIFIED/REMOVED work targets this file. Note its dir (the cobra subcommand group). +- **Not found** for an ADDED op → this is new-command work. Identify the right subcommand group (existing dir like `cli/src/cmd/gateway/subscription/`, or a new one). +- **Not found** for a MODIFIED op → the endpoint has no CLI coverage; treat as ADDED or report it, don't silently skip. + +### Step 3 — Reconcile each operation + +Open `references/cli-command-anatomy.md` and follow the template for the operation's family. Then, per classification: + +**MODIFIED — update an existing command** +- New/renamed **parameter** → add/rename the cobra flag (use or add a `Flag*` constant in `cli/src/utils/flags.go`; wire with `utils.AddStringFlag`/`AddBoolFlag`), update the path/query building, and adjust validation (`MarkFlagRequired`, mutual-exclusion checks). +- Changed **request body** → update the struct/`map` marshalled into the payload and any `--file` CR parsing (`gateway.ParseResourceCR`). +- New **required** field → add validation that returns a clear `fmt.Errorf` before the request; add a flag or CR field for it. +- Changed **response** → update any local response struct and the print path (`PrintJSONResponse` needs no change for pass-through; typed decoders do). +- Changed **path/method** → update the path constant/helper and the client verb (`Get`/`PostJSON`/`Put`/`Delete`/`PostYAML`). + +**ADDED — scaffold a new command** +1. Pick the subcommand group dir. Reuse an existing one if the resource already has a group; otherwise create a new dir + `root.go` (a `Cmd` cobra group) and register it in the family root (`cli/src/cmd//root.go`). +2. Add the path constant (gateway) or use `OrgScopedPath` (devportal). +3. Create `.go` from the matching template in the reference: consts `CmdLiteral`/`CmdExample`, flag vars, the `cobra.Command`, `init()` wiring flags, and `runCommand` doing validate → client → request → status-check → print. +4. Register the new command in the group's `root.go` via `AddCommand`. +5. Map the operation's OAuth2 scope/auth to the family's auth model (gateway selection flags vs devportal resolve+`--insecure`) — the reference explains both. + +**REMOVED — retire a command** +- Confirm with the user first (removing a command is a breaking CLI change). If confirmed, delete the `.go`, drop its `AddCommand` line, remove now-unused path constants, and delete its tests. + +### Step 4 — Keep the surrounding artifacts in sync + +A command change is not done until these match: + +- **Flag constants** — new flags need a `Flag*` entry in `cli/src/utils/flags.go` (don't hardcode flag strings). +- **Path constants** — new/changed gateway endpoints need entries in `cli/src/utils/constants.go`. +- **Registration** — every new command/group is wired via `AddCommand` up to the family root. +- **Docs** (manually maintained, not generated) — update the relevant `docs/cli//README.md` and, if a top-level command/flag changed, `docs/cli/reference.md`. Match the existing entry format (CLI Command + example blocks). +- **Tests** — mirror the neighboring `*_test.go` / `commands_test.go` in the same dir. Add cases for new flags/validation; update expectations for changed payloads. + +### Step 5 — Build, test, and report + +From `cli/`: + +```bash +make build # builds (runs tests first) +make test # tests only +# or, faster while iterating, from cli/src: go build ./... && go test ./... +``` + +Fix compile/test failures. Then report: which operations were ADDED/MODIFIED/REMOVED, the files touched (commands, constants, flags, registration, docs, tests), the build/test result, and anything that needs manual follow-up (e.g. a REMOVED op awaiting confirmation, or a platform-api change with no CLI consumer). + +## Guardrails + +- **Never invent an endpoint.** Every path/verb/parameter a command uses must exist in the spec diff or the current spec. If the spec is ambiguous, read the operation's schema in the spec file rather than guessing. +- **Match the family's conventions exactly** — the gateway, devportal, and ai-ws families differ in client construction, path handling (constants vs `OrgScopedPath` vs query-param scoping), verb vocabulary (`create` vs `push`), auth flags, and error wrapping. Copy the closest existing sibling command; don't blend patterns across families. +- **Treat CLI removals/renames as breaking.** Confirm before removing or renaming a command, flag, or its short form. +- **Keep the license header** — every Go file starts with the Apache-2.0 WSO2 header block (copy it from any sibling file). +- Prefer editing a sibling-derived copy over authoring from scratch; the templates in the reference are a fallback, the real source of truth is the existing commands in the same group. diff --git a/.agents/skills/sync-cli-with-openapi/references/cli-command-anatomy.md b/.agents/skills/sync-cli-with-openapi/references/cli-command-anatomy.md index b4bc70e4a7..8967584d0d 100644 --- a/.agents/skills/sync-cli-with-openapi/references/cli-command-anatomy.md +++ b/.agents/skills/sync-cli-with-openapi/references/cli-command-anatomy.md @@ -240,7 +240,7 @@ For **list/get/delete/update**, copy `cli/src/cmd/devportal/subplan/list.go`, `s ## 3b. AI-workspace family (`internal/aiworkspace`) -Consumes the **LLM/MCP subset** of `platform-api/src/resources/openapi.yaml` — `/llm-providers`, `/llm-proxies`, `/mcp-proxies` (and `llm-provider-templates`) — reached under the `/api-proxy/api/v0.9/` prefix. Command literal is `ap ai-ws`. Existing groups: `llmprovider` (`llm-provider`), `llmproxy` (`llm-proxy`), `mcpproxy` (`mcp-proxy`). Its verb vocabulary differs from gateway/devportal: **`push`** (create-or-update from a `--file` JSON artifact), **`edit`**, `get`, `list`, `delete`. +Consumes the **LLM/MCP subset** of `platform-api/src/resources/openapi.yaml` — `/llm-providers`, `/llm-proxies`, `/mcp-proxies` (and `llm-provider-templates`) — reached under the `/api-proxy/api/v0.9/` prefix. Command literal is `ap ai-workspace`. Existing groups: `llmprovider` (`llm-provider`), `llmproxy` (`llm-proxy`), `mcpproxy` (`mcp-proxy`). Its verb vocabulary differs from gateway/devportal: **`push`** (create-or-update from a `--file` JSON artifact), **`edit`**, `get`, `list`, `delete`. **Key helpers** - `config.LoadConfig()` → `aiworkspace.ResolveAIWorkspace(cfg, name, platform)` → `(*config.AIWorkspace, resolvedPlatform, error)`. @@ -255,7 +255,7 @@ Consumes the **LLM/MCP subset** of `platform-api/src/resources/openapi.yaml` — - Auth is handled by the client from workspace config or env vars (`WSO2AP_AIWORKSPACE_USERNAME`/`_PASSWORD`/`_TOKEN`/`_API_KEY`, header `x-wso2-api-key`). - Standard flags: `--org` (`FlagOrgID`), `--file` (`FlagFile`), `--display-name` (`FlagName`), `--platform` (`FlagPlatform`), `--insecure` (`FlagInsecure`). -### Template — ai-ws push (create-or-update from `--file`) +### Template — ai-workspace push (create-or-update from `--file`) Copy the closest sibling — `cli/src/cmd/aiws/llmprovider/push.go` (create/update), `get.go`, `list.go`, `delete.go`, `edit.go`. The shape: diff --git a/.vscode/launch.json b/.vscode/launch.json index 7b1bc5f09d..f53fb6c9eb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -28,22 +28,9 @@ "DATABASE_DRIVER": "sqlite3", "DATABASE_DB_PATH": "${workspaceFolder}/platform-api/data/api_platform.db", "DATABASE_EXECUTE_SCHEMA_DDL": "true", - "DB_SCHEMA_PATH": "./internal/database/schema.postgres.sql", - // Auth — local HMAC JWT, signature validation skipped for local dev - "AUTH_JWT_SKIP_VALIDATION": "true", - "AUTH_JWT_ENABLED": "true", - "AUTH_JWT_ISSUER": "platform-api", - "AUTH_IDP_ENABLED": "false", - // File-based auth — local username/password login (admin / admin). - // Mirrors [auth.file_based] in portals/ai-workspace/configs/config-platform-api.toml. - "AUTH_FILE_BASED_ENABLED": "true", - "AUTH_FILE_BASED_ORGANIZATION_ID": "99089a17-72e0-4dd8-a2f4-c8dfbb085295", - "AUTH_FILE_BASED_ORGANIZATION_NAME": "AP Organization", - "AUTH_FILE_BASED_ORGANIZATION_HANDLE": "ap-org", - "AUTH_FILE_BASED_ORGANIZATION_REGION": "us", - // Users must be a JSON *string* with lowercase keys (username/password_hash/scopes) - "AUTH_FILE_BASED_USERS": "[{\"username\":\"admin\",\"password_hash\":\"$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ.\",\"scopes\":\"ap:organization:read ap:organization:manage ap:gateway:read ap:gateway:create ap:gateway:update ap:gateway:delete ap:gateway:manage ap:gateway:token:read ap:gateway:token:create ap:gateway:token:delete ap:gateway:token:manage ap:gateway:policy:read ap:gateway:policy:create ap:gateway:policy:delete ap:gateway:policy:manage ap:gateway:artifacts:read ap:gateway:manifest:read ap:rest_api:read ap:rest_api:create ap:rest_api:update ap:rest_api:delete ap:rest_api:manage ap:rest_api:import ap:rest_api:deployment:read ap:rest_api:deployment:create ap:rest_api:deployment:delete ap:rest_api:deployment:manage ap:rest_api:api_key:read ap:rest_api:api_key:create ap:rest_api:api_key:delete ap:rest_api:api_key:manage ap:llm_provider:read ap:llm_provider:create ap:llm_provider:update ap:llm_provider:delete ap:llm_provider:manage ap:llm_provider:api_key:read ap:llm_provider:api_key:create ap:llm_provider:api_key:delete ap:llm_provider:api_key:manage ap:llm_provider:deployment:read ap:llm_provider:deployment:create ap:llm_provider:deployment:delete ap:llm_provider:deployment:manage ap:llm_proxy:read ap:llm_proxy:create ap:llm_proxy:update ap:llm_proxy:delete ap:llm_proxy:manage ap:llm_proxy:deployment:read ap:llm_proxy:deployment:create ap:llm_proxy:deployment:delete ap:llm_proxy:deployment:manage ap:mcp_proxy:read ap:mcp_proxy:create ap:mcp_proxy:update ap:mcp_proxy:delete ap:mcp_proxy:manage ap:mcp_proxy:deployment:read ap:mcp_proxy:deployment:create ap:mcp_proxy:deployment:delete ap:mcp_proxy:deployment:manage ap:application:read ap:application:create ap:application:update ap:application:delete ap:application:manage ap:subscription:read ap:subscription:create ap:subscription:update ap:subscription:delete ap:subscription:manage ap:subscription_plan:read ap:subscription_plan:create ap:subscription_plan:update ap:subscription_plan:delete ap:subscription_plan:manage ap:project:manage ap:llm_template:manage\"}]", - "PLATFORM_SECRET_ENCRYPTION_KEY": "e5e3b427bd47de988ce45128d2c00681f61e68c99123a7c1054b1d4ef4893a7b" + "DB_SCHEMA_PATH": "./internal/database/schema.sqlite.sql", + // JWT - skip signature validation in development + "JWT_SKIP_VALIDATION": "true", }, }, { diff --git a/cli/src/cmd/aiws/build.go b/cli/src/cmd/aiws/build.go index 606f466440..9f48fef6c3 100644 --- a/cli/src/cmd/aiws/build.go +++ b/cli/src/cmd/aiws/build.go @@ -79,7 +79,7 @@ func runBuildCommand() error { return fmt.Errorf("AI workspace validation failed for %q: %w", wsConfig.Name, err) } - fmt.Printf("AI workspace artifact %q (kind: %s) validated successfully\n", artifact.ResourceName, artifact.BaseKind) + fmt.Printf("AI workspace artifact %q (kind: %s) built successfully\n", artifact.ResourceName, artifact.BaseKind) return nil } diff --git a/cli/src/cmd/devportal/apikey/generate.go b/cli/src/cmd/devportal/apikey/generate.go index 39261e7e8a..dfe3c4faef 100644 --- a/cli/src/cmd/devportal/apikey/generate.go +++ b/cli/src/cmd/devportal/apikey/generate.go @@ -69,8 +69,8 @@ type apiKeyRequest struct { } var generateCmd = &cobra.Command{ - Use: GenerateCmdLiteral, - Short: "Generate a DevPortal API key", + Use: GenerateCmdLiteral, + Short: "Generate a DevPortal API key", Long: "Generates an API key for an API in the selected DevPortal. The plaintext secret is " + "returned once in the response and never persisted. Run without the required flags to be " + "prompted interactively.", diff --git a/cli/src/cmd/devportal/build.go b/cli/src/cmd/devportal/build.go index 1b56dc31f2..fbd660cf43 100644 --- a/cli/src/cmd/devportal/build.go +++ b/cli/src/cmd/devportal/build.go @@ -265,12 +265,17 @@ func buildSingleDevPortalArchive(projectRoot, buildDir string, portalConfig *pro return "", err } - if err := applyManifestOverrides(projectRoot, portalConfig); err != nil { + stagingDir, err := createDevPortalArchiveStagingDir(projectRoot, buildDir, portalConfig) + if err != nil { return "", err } - stagingDir, err := createDevPortalArchiveStagingDir(projectRoot, buildDir, portalConfig) - if err != nil { + // Stamp any --reference-id / --gateway-type overrides into the *staged* + // manifest, never the project's source manifest, so build flags do not leak + // back into the workspace. + stagedManifest := filepath.Join(stagingDir, archiveMetadataFileName) + if err := applyManifestOverrides(stagedManifest, portalConfig.Name); err != nil { + _ = os.RemoveAll(stagingDir) return "", err } @@ -287,31 +292,28 @@ func buildSingleDevPortalArchive(projectRoot, buildDir string, portalConfig *pro } // applyManifestOverrides stamps the build-time --reference-id / --gateway-type -// flags into the devportal manifest under spec.referenceID / spec.gatewayType. -// The manifest itself carries no reference ID by default; supplying one at +// flags into the staged devportal manifest at manifestPath, under +// spec.referenceID / spec.gatewayType. It operates on the archive's staged copy, +// never the project's source manifest, so the flags do not leak back into the +// workspace. The manifest carries no reference ID by default; supplying one at // build time lets the same artifact be published to different devportals (each // wired to a different gateway). Existing manifest fields and ordering are // preserved. -func applyManifestOverrides(projectRoot string, portalConfig *project.PortalConfig) error { +func applyManifestOverrides(manifestPath, portalName string) error { referenceID := strings.TrimSpace(buildReferenceID) gatewayType := strings.TrimSpace(buildGatewayType) if referenceID == "" && gatewayType == "" { return nil } - manifestPath := resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.MetadataFile) - if err := ensureWithinProjectRoot(projectRoot, manifestPath, portalConfig.Name, "metadataFile"); err != nil { - return err - } - data, err := os.ReadFile(manifestPath) if err != nil { - return fmt.Errorf("failed to read devportal manifest for config %q: %w", portalConfig.Name, err) + return fmt.Errorf("failed to read devportal manifest for config %q: %w", portalName, err) } var doc yaml.Node if err := yaml.Unmarshal(data, &doc); err != nil { - return fmt.Errorf("failed to parse devportal manifest for config %q: %w", portalConfig.Name, err) + return fmt.Errorf("failed to parse devportal manifest for config %q: %w", portalName, err) } root := &doc @@ -319,7 +321,7 @@ func applyManifestOverrides(projectRoot string, portalConfig *project.PortalConf root = root.Content[0] } if root.Kind != yaml.MappingNode { - return fmt.Errorf("devportal manifest for config %q is not a mapping", portalConfig.Name) + return fmt.Errorf("devportal manifest for config %q is not a mapping", portalName) } spec := mappingValueNode(root, "spec") @@ -336,10 +338,10 @@ func applyManifestOverrides(projectRoot string, portalConfig *project.PortalConf out, err := marshalNode(&doc) if err != nil { - return fmt.Errorf("failed to marshal devportal manifest for config %q: %w", portalConfig.Name, err) + return fmt.Errorf("failed to marshal devportal manifest for config %q: %w", portalName, err) } if err := os.WriteFile(manifestPath, out, 0644); err != nil { - return fmt.Errorf("failed to write devportal manifest for config %q: %w", portalConfig.Name, err) + return fmt.Errorf("failed to write devportal manifest for config %q: %w", portalName, err) } return nil diff --git a/cli/src/cmd/devportal/build_test.go b/cli/src/cmd/devportal/build_test.go index d807ae9363..525adf7474 100644 --- a/cli/src/cmd/devportal/build_test.go +++ b/cli/src/cmd/devportal/build_test.go @@ -18,6 +18,8 @@ package devportal import ( + "archive/zip" + "io" "os" "path/filepath" "strings" @@ -70,8 +72,8 @@ func TestEnsureWithinProjectRoot_AllowsContainedPaths(t *testing.T) { projectRoot := t.TempDir() cases := []string{ - projectRoot, // the root itself - filepath.Join(projectRoot, "devportal"), // nested dir + projectRoot, // the root itself + filepath.Join(projectRoot, "devportal"), // nested dir filepath.Join(projectRoot, "devportal", "metadata.yaml"), // nested file } for _, path := range cases { @@ -177,7 +179,7 @@ func TestRunBuildCommand_RegistersExistingFolderAndArchives(t *testing.T) { } } -func TestRunBuildCommand_StampsReferenceIDAndGatewayType(t *testing.T) { +func TestRunBuildCommand_StampsReferenceIDAndGatewayTypeIntoArchiveOnly(t *testing.T) { projectRoot := createProjectFixture(t) createDevPortalFolderFixture(t, projectRoot, "devportal") buildProjectDir = projectRoot @@ -193,20 +195,61 @@ func TestRunBuildCommand_StampsReferenceIDAndGatewayType(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - manifestData, readErr := os.ReadFile(filepath.Join(projectRoot, "devportal", "devportal.yaml")) + // The project's source manifest must be left untouched — the build flags + // must not leak back into the workspace. + sourceData, readErr := os.ReadFile(filepath.Join(projectRoot, "devportal", "devportal.yaml")) if readErr != nil { - t.Fatalf("failed to read manifest: %v", readErr) + t.Fatalf("failed to read source manifest: %v", readErr) } + for _, unwanted := range []string{"referenceID", "gatewayType"} { + if strings.Contains(string(sourceData), unwanted) { + t.Fatalf("source manifest should not be mutated by build, but contains %q: %s", unwanted, sourceData) + } + } + + // The stamped values must be present in the archived (staged) manifest. + archived := readFileFromZip(t, filepath.Join(projectRoot, "build", "devportal.zip"), archiveMetadataFileName) for _, want := range []string{ "referenceID: 1ba42a09-45c0-40f8-a1bf-e4aa7cde1575", "gatewayType: wso2/api-platform", } { - if !strings.Contains(string(manifestData), want) { - t.Fatalf("expected stamped manifest to contain %q, got %q", want, string(manifestData)) + if !strings.Contains(archived, want) { + t.Fatalf("expected archived manifest to contain %q, got %q", want, archived) } } } +// readFileFromZip returns the contents of the named entry inside the zip at +// zipPath, failing the test if the archive or entry cannot be read. +func readFileFromZip(t *testing.T, zipPath, entryName string) string { + t.Helper() + + reader, err := zip.OpenReader(zipPath) + if err != nil { + t.Fatalf("failed to open archive %s: %v", zipPath, err) + } + defer reader.Close() + + for _, file := range reader.File { + if file.Name != entryName { + continue + } + rc, err := file.Open() + if err != nil { + t.Fatalf("failed to open %s in archive: %v", entryName, err) + } + defer rc.Close() + data, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("failed to read %s from archive: %v", entryName, err) + } + return string(data) + } + + t.Fatalf("entry %q not found in archive %s", entryName, zipPath) + return "" +} + func TestRunBuildCommand_BuildsMultipleConfiguredDevPortalsAndCleansBuildDir(t *testing.T) { projectRoot := createProjectFixture(t) diff --git a/cli/src/cmd/devportal/commands_test.go b/cli/src/cmd/devportal/commands_test.go index 56e2c6f83e..29c5ca1c14 100644 --- a/cli/src/cmd/devportal/commands_test.go +++ b/cli/src/cmd/devportal/commands_test.go @@ -428,4 +428,3 @@ func writeDevPortalConfig(t *testing.T, cfg *config.Config) { t.Helper() testutil.WriteCLIConfig(t, cfg) } - diff --git a/cli/src/cmd/devportal/gen.go b/cli/src/cmd/devportal/gen.go index a6adbc8c2f..c7a2bc4dfb 100644 --- a/cli/src/cmd/devportal/gen.go +++ b/cli/src/cmd/devportal/gen.go @@ -105,12 +105,10 @@ func runGenCommand() error { return err } - if err := os.MkdirAll(portalRoot, 0755); err != nil { - return fmt.Errorf("failed to create devportal directory: %w", err) - } - - // definition.yaml is copied from the project's home definition so the - // devportal artifact carries the same API definition. + // Verify all inputs before creating anything on disk, so a missing + // definition never leaves an empty devportal directory behind. definition.yaml + // is copied from the project's home definition so the devportal artifact + // carries the same API definition. definitionSource := resolveProjectPath(projectRoot, projectConfig.FilePaths.Definition) if _, err := os.Stat(definitionSource); err != nil { if os.IsNotExist(err) { @@ -118,6 +116,19 @@ func runGenCommand() error { } return fmt.Errorf("failed to inspect project definition file: %w", err) } + + if err := os.MkdirAll(portalRoot, 0755); err != nil { + return fmt.Errorf("failed to create devportal directory: %w", err) + } + // Any failure after the directory is created must not leave a partial + // artifact behind; remove it on error until the artifact is fully written. + cleanupOnError := true + defer func() { + if cleanupOnError { + _ = os.RemoveAll(portalRoot) + } + }() + if err := copyFile(definitionSource, filepath.Join(portalRoot, "definition.yaml")); err != nil { return err } @@ -132,6 +143,10 @@ func runGenCommand() error { return fmt.Errorf("failed to write devportal manifest: %w", err) } + // The artifact is now complete on disk; a later config-registration failure + // should not delete the user's generated files. + cleanupOnError = false + // Register the generated artifact in the project config so `ap devportal // build` archives it from the config instead of having to re-detect the // folder. Skip if a config entry already points at this folder. @@ -253,5 +268,25 @@ spec: ` func renderGeneratedDevPortalManifest(kind, name, displayName, version string) string { - return fmt.Sprintf(generatedDevPortalTemplate, kind, name, displayName, version) + return fmt.Sprintf( + generatedDevPortalTemplate, + yamlScalar(kind), + yamlScalar(name), + yamlScalar(displayName), + yamlScalar(version), + ) +} + +// yamlScalar encodes v as a single-line YAML scalar, quoting or escaping it as +// needed so values containing special characters (":", "#", leading indicators +// like "*"/"&", newlines, etc.) cannot break the generated manifest. The result +// is safe to interpolate inline after a "key: " in the template. Marshalling a +// string never fails and never produces a block scalar, so it always stays on a +// single line; only the trailing newline needs trimming. +func yamlScalar(v string) string { + encoded, err := yaml.Marshal(v) + if err != nil { + return v + } + return strings.TrimRight(string(encoded), "\n") } diff --git a/cli/src/cmd/devportal/gen_test.go b/cli/src/cmd/devportal/gen_test.go index 626a3ed8e8..69a0e4804c 100644 --- a/cli/src/cmd/devportal/gen_test.go +++ b/cli/src/cmd/devportal/gen_test.go @@ -22,6 +22,8 @@ import ( "path/filepath" "strings" "testing" + + "gopkg.in/yaml.v3" ) func TestRunGenCommand_RequiresProjectDirectory(t *testing.T) { @@ -101,3 +103,62 @@ func TestRunGenCommand_StopsWhenDevPortalExists(t *testing.T) { t.Fatalf("expected already-exists error, got %v", err) } } + +func TestRenderGeneratedDevPortalManifest_EscapesSpecialCharacters(t *testing.T) { + // Values with YAML-significant characters must not break the generated + // manifest: it must stay valid YAML and round-trip to the same values. + kind := "RestApi" + name := "foo: bar #1" + displayName := "*My \"API\": v2" + version := "1.0" + + manifest := renderGeneratedDevPortalManifest(kind, name, displayName, version) + + var parsed struct { + Kind string `yaml:"kind"` + Metadata struct { + Name string `yaml:"name"` + } `yaml:"metadata"` + Spec struct { + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` + } `yaml:"spec"` + } + if err := yaml.Unmarshal([]byte(manifest), &parsed); err != nil { + t.Fatalf("generated manifest is not valid YAML: %v\n%s", err, manifest) + } + + if parsed.Kind != kind { + t.Errorf("kind: got %q, want %q", parsed.Kind, kind) + } + if parsed.Metadata.Name != name { + t.Errorf("metadata.name: got %q, want %q", parsed.Metadata.Name, name) + } + if parsed.Spec.DisplayName != displayName { + t.Errorf("spec.displayName: got %q, want %q", parsed.Spec.DisplayName, displayName) + } + if parsed.Spec.Version != version { + t.Errorf("spec.version: got %q, want %q", parsed.Spec.Version, version) + } +} + +func TestRunGenCommand_LeavesNoPartialDirWhenDefinitionMissing(t *testing.T) { + projectRoot := createProjectFixture(t) + // Remove the project's home definition so generation fails after inputs are + // verified but before (previously) the directory would have been created. + if err := os.Remove(filepath.Join(projectRoot, "definition.yaml")); err != nil { + t.Fatalf("failed to remove project definition: %v", err) + } + genProjectDir = projectRoot + + err := runGenCommand() + if err == nil || !strings.Contains(err.Error(), "unable to find project definition file") { + t.Fatalf("expected missing-definition error, got %v", err) + } + + // The failed run must not leave a devportal directory behind (which would + // otherwise trip the already-exists guard on the next run). + if _, statErr := os.Stat(filepath.Join(projectRoot, "devportal")); !os.IsNotExist(statErr) { + t.Fatalf("expected no devportal directory after a failed gen, got err=%v", statErr) + } +} diff --git a/cli/src/cmd/devportal/org/delete.go b/cli/src/cmd/devportal/org/delete.go index cf461b2b27..d25044fca1 100644 --- a/cli/src/cmd/devportal/org/delete.go +++ b/cli/src/cmd/devportal/org/delete.go @@ -93,4 +93,3 @@ func runDeleteCommand() error { fmt.Printf("Organization deleted from devportal %s (platform: %s)\n", devPortal.Name, resolvedPlatform) return internaldevportal.PrintJSONResponse(resp) } - diff --git a/cli/src/cmd/devportal/restapi/commands_test.go b/cli/src/cmd/devportal/restapi/commands_test.go index 1eb5ea41b9..4398fa3d45 100644 --- a/cli/src/cmd/devportal/restapi/commands_test.go +++ b/cli/src/cmd/devportal/restapi/commands_test.go @@ -338,4 +338,3 @@ func TestRestAPIImports(t *testing.T) { _ = multipart.ErrMessageTooLarge _ = os.ErrNotExist } - diff --git a/cli/src/cmd/devportal/subscription/delete.go b/cli/src/cmd/devportal/subscription/delete.go index b8e4e6a83f..f77e846a13 100644 --- a/cli/src/cmd/devportal/subscription/delete.go +++ b/cli/src/cmd/devportal/subscription/delete.go @@ -104,4 +104,3 @@ func runDeleteCommand() error { fmt.Printf("Platform subscription deleted from devportal %s (platform: %s)\n", devPortal.Name, resolvedPlatform) return internaldevportal.PrintJSONResponse(resp) } - diff --git a/cli/src/cmd/devportal/subscription/get.go b/cli/src/cmd/devportal/subscription/get.go index 18ef90fee3..924d5ab507 100644 --- a/cli/src/cmd/devportal/subscription/get.go +++ b/cli/src/cmd/devportal/subscription/get.go @@ -110,4 +110,3 @@ func runGetCommand() error { } return internaldevportal.PrintJSONResponse(resp) } - diff --git a/cli/src/cmd/project/init.go b/cli/src/cmd/project/init.go index 02113b0fc6..496fa0bda8 100644 --- a/cli/src/cmd/project/init.go +++ b/cli/src/cmd/project/init.go @@ -79,7 +79,6 @@ func runInitCommand() error { } displayName = strings.TrimSpace(displayName) - projectType = strings.ToLower(strings.TrimSpace(projectType)) if displayName == "" { return fmt.Errorf("display name is required") From 0b1edd9b04de02f5d7e2659ed1f4623b0922c696 Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Mon, 6 Jul 2026 15:51:14 +0530 Subject: [PATCH 26/27] change directory name --- cli/src/cmd/{aiws => aiworkspace}/add.go | 0 cli/src/cmd/{aiws => aiworkspace}/build.go | 0 cli/src/cmd/{aiws => aiworkspace}/build_test.go | 0 cli/src/cmd/{aiws => aiworkspace}/current.go | 0 cli/src/cmd/{aiws => aiworkspace}/edit.go | 0 cli/src/cmd/{aiws => aiworkspace}/list.go | 0 cli/src/cmd/{aiws => aiworkspace}/llmprovider/delete.go | 0 cli/src/cmd/{aiws => aiworkspace}/llmprovider/get.go | 0 cli/src/cmd/{aiws => aiworkspace}/llmprovider/list.go | 0 cli/src/cmd/{aiws => aiworkspace}/llmprovider/root.go | 0 cli/src/cmd/{aiws => aiworkspace}/llmproxy/delete.go | 0 cli/src/cmd/{aiws => aiworkspace}/llmproxy/get.go | 0 cli/src/cmd/{aiws => aiworkspace}/llmproxy/list.go | 0 cli/src/cmd/{aiws => aiworkspace}/llmproxy/root.go | 0 cli/src/cmd/{aiws => aiworkspace}/mcpproxy/delete.go | 0 cli/src/cmd/{aiws => aiworkspace}/mcpproxy/get.go | 0 cli/src/cmd/{aiws => aiworkspace}/mcpproxy/list.go | 0 cli/src/cmd/{aiws => aiworkspace}/mcpproxy/root.go | 0 cli/src/cmd/{aiws => aiworkspace}/push.go | 0 cli/src/cmd/{aiws => aiworkspace}/remove.go | 0 cli/src/cmd/{aiws => aiworkspace}/root.go | 0 cli/src/cmd/{aiws => aiworkspace}/use.go | 0 22 files changed, 0 insertions(+), 0 deletions(-) rename cli/src/cmd/{aiws => aiworkspace}/add.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/build.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/build_test.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/current.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/edit.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/list.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/llmprovider/delete.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/llmprovider/get.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/llmprovider/list.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/llmprovider/root.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/llmproxy/delete.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/llmproxy/get.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/llmproxy/list.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/llmproxy/root.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/mcpproxy/delete.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/mcpproxy/get.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/mcpproxy/list.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/mcpproxy/root.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/push.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/remove.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/root.go (100%) rename cli/src/cmd/{aiws => aiworkspace}/use.go (100%) diff --git a/cli/src/cmd/aiws/add.go b/cli/src/cmd/aiworkspace/add.go similarity index 100% rename from cli/src/cmd/aiws/add.go rename to cli/src/cmd/aiworkspace/add.go diff --git a/cli/src/cmd/aiws/build.go b/cli/src/cmd/aiworkspace/build.go similarity index 100% rename from cli/src/cmd/aiws/build.go rename to cli/src/cmd/aiworkspace/build.go diff --git a/cli/src/cmd/aiws/build_test.go b/cli/src/cmd/aiworkspace/build_test.go similarity index 100% rename from cli/src/cmd/aiws/build_test.go rename to cli/src/cmd/aiworkspace/build_test.go diff --git a/cli/src/cmd/aiws/current.go b/cli/src/cmd/aiworkspace/current.go similarity index 100% rename from cli/src/cmd/aiws/current.go rename to cli/src/cmd/aiworkspace/current.go diff --git a/cli/src/cmd/aiws/edit.go b/cli/src/cmd/aiworkspace/edit.go similarity index 100% rename from cli/src/cmd/aiws/edit.go rename to cli/src/cmd/aiworkspace/edit.go diff --git a/cli/src/cmd/aiws/list.go b/cli/src/cmd/aiworkspace/list.go similarity index 100% rename from cli/src/cmd/aiws/list.go rename to cli/src/cmd/aiworkspace/list.go diff --git a/cli/src/cmd/aiws/llmprovider/delete.go b/cli/src/cmd/aiworkspace/llmprovider/delete.go similarity index 100% rename from cli/src/cmd/aiws/llmprovider/delete.go rename to cli/src/cmd/aiworkspace/llmprovider/delete.go diff --git a/cli/src/cmd/aiws/llmprovider/get.go b/cli/src/cmd/aiworkspace/llmprovider/get.go similarity index 100% rename from cli/src/cmd/aiws/llmprovider/get.go rename to cli/src/cmd/aiworkspace/llmprovider/get.go diff --git a/cli/src/cmd/aiws/llmprovider/list.go b/cli/src/cmd/aiworkspace/llmprovider/list.go similarity index 100% rename from cli/src/cmd/aiws/llmprovider/list.go rename to cli/src/cmd/aiworkspace/llmprovider/list.go diff --git a/cli/src/cmd/aiws/llmprovider/root.go b/cli/src/cmd/aiworkspace/llmprovider/root.go similarity index 100% rename from cli/src/cmd/aiws/llmprovider/root.go rename to cli/src/cmd/aiworkspace/llmprovider/root.go diff --git a/cli/src/cmd/aiws/llmproxy/delete.go b/cli/src/cmd/aiworkspace/llmproxy/delete.go similarity index 100% rename from cli/src/cmd/aiws/llmproxy/delete.go rename to cli/src/cmd/aiworkspace/llmproxy/delete.go diff --git a/cli/src/cmd/aiws/llmproxy/get.go b/cli/src/cmd/aiworkspace/llmproxy/get.go similarity index 100% rename from cli/src/cmd/aiws/llmproxy/get.go rename to cli/src/cmd/aiworkspace/llmproxy/get.go diff --git a/cli/src/cmd/aiws/llmproxy/list.go b/cli/src/cmd/aiworkspace/llmproxy/list.go similarity index 100% rename from cli/src/cmd/aiws/llmproxy/list.go rename to cli/src/cmd/aiworkspace/llmproxy/list.go diff --git a/cli/src/cmd/aiws/llmproxy/root.go b/cli/src/cmd/aiworkspace/llmproxy/root.go similarity index 100% rename from cli/src/cmd/aiws/llmproxy/root.go rename to cli/src/cmd/aiworkspace/llmproxy/root.go diff --git a/cli/src/cmd/aiws/mcpproxy/delete.go b/cli/src/cmd/aiworkspace/mcpproxy/delete.go similarity index 100% rename from cli/src/cmd/aiws/mcpproxy/delete.go rename to cli/src/cmd/aiworkspace/mcpproxy/delete.go diff --git a/cli/src/cmd/aiws/mcpproxy/get.go b/cli/src/cmd/aiworkspace/mcpproxy/get.go similarity index 100% rename from cli/src/cmd/aiws/mcpproxy/get.go rename to cli/src/cmd/aiworkspace/mcpproxy/get.go diff --git a/cli/src/cmd/aiws/mcpproxy/list.go b/cli/src/cmd/aiworkspace/mcpproxy/list.go similarity index 100% rename from cli/src/cmd/aiws/mcpproxy/list.go rename to cli/src/cmd/aiworkspace/mcpproxy/list.go diff --git a/cli/src/cmd/aiws/mcpproxy/root.go b/cli/src/cmd/aiworkspace/mcpproxy/root.go similarity index 100% rename from cli/src/cmd/aiws/mcpproxy/root.go rename to cli/src/cmd/aiworkspace/mcpproxy/root.go diff --git a/cli/src/cmd/aiws/push.go b/cli/src/cmd/aiworkspace/push.go similarity index 100% rename from cli/src/cmd/aiws/push.go rename to cli/src/cmd/aiworkspace/push.go diff --git a/cli/src/cmd/aiws/remove.go b/cli/src/cmd/aiworkspace/remove.go similarity index 100% rename from cli/src/cmd/aiws/remove.go rename to cli/src/cmd/aiworkspace/remove.go diff --git a/cli/src/cmd/aiws/root.go b/cli/src/cmd/aiworkspace/root.go similarity index 100% rename from cli/src/cmd/aiws/root.go rename to cli/src/cmd/aiworkspace/root.go diff --git a/cli/src/cmd/aiws/use.go b/cli/src/cmd/aiworkspace/use.go similarity index 100% rename from cli/src/cmd/aiws/use.go rename to cli/src/cmd/aiworkspace/use.go From a9ae5f5fe1da684022ead95c7e2a860ad712a3ae Mon Sep 17 00:00:00 2001 From: ShakyaPr Date: Mon, 6 Jul 2026 16:04:19 +0530 Subject: [PATCH 27/27] update push command to apply command --- cli/src/cmd/aiworkspace/apply.go | 122 ++++++++++++++++++++ cli/src/cmd/aiworkspace/build.go | 6 +- cli/src/cmd/aiworkspace/llmprovider/root.go | 2 +- cli/src/cmd/aiworkspace/llmproxy/root.go | 2 +- cli/src/cmd/aiworkspace/mcpproxy/root.go | 2 +- cli/src/cmd/aiworkspace/push.go | 122 -------------------- cli/src/cmd/aiworkspace/root.go | 8 +- cli/src/cmd/root.go | 2 +- docs/cli/README.md | 2 +- docs/cli/ai-workspace/README.md | 30 ++--- docs/cli/end-to-end-workflow.md | 12 +- 11 files changed, 155 insertions(+), 155 deletions(-) create mode 100644 cli/src/cmd/aiworkspace/apply.go delete mode 100644 cli/src/cmd/aiworkspace/push.go diff --git a/cli/src/cmd/aiworkspace/apply.go b/cli/src/cmd/aiworkspace/apply.go new file mode 100644 index 0000000000..fe8770dc9b --- /dev/null +++ b/cli/src/cmd/aiworkspace/apply.go @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + ApplyCmdLiteral = "apply" + ApplyCmdExample = `# Generate and apply the AI workspace artifact from the current project +ap ai-workspace apply + +# Apply a proxy or MCP artifact (--project-id is required for those kinds) +ap ai-workspace apply --project-id + +# Resolve ENV_CLI_* placeholders from a specific env file (defaults to .env in the project root) +ap ai-workspace apply --env-file ./secrets.env + +# Apply from a specific project directory using a specific AI workspace +ap ai-workspace apply -f /path/to/project --project-id --display-name my-workspace --platform eu` +) + +var ( + applyProjectDir string + applyProjectID string + applyEnvFile string + applyName string + applyPlatform string + applyInsecure bool + applyOutput string +) + +var applyCmd = &cobra.Command{ + Use: ApplyCmdLiteral, + Short: "Generate and apply an AI workspace artifact", + Long: "Generate the creation payload from the project's metadata.yaml, runtime.yaml and definition.yaml, " + + "then create the artifact on the WSO2 API Platform AI workspace. The target endpoint is selected by the " + + "artifact kind declared in the project (LlmProvider, LlmProxy, Mcp). For the LlmProxy and Mcp kinds " + + "--project-id is required.", + Example: ApplyCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runApplyCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(applyCmd, utils.FlagFile, &applyProjectDir, "", "Path to the project directory (defaults to current directory)") + utils.AddStringFlag(applyCmd, utils.FlagProjectID, &applyProjectID, "", "Project ID (required for LlmProxy and Mcp kinds)") + utils.AddStringFlag(applyCmd, utils.FlagEnvFile, &applyEnvFile, "", "Path to an env file resolving ENV_CLI_* placeholders (defaults to .env in the project root)") + utils.AddStringFlag(applyCmd, utils.FlagName, &applyName, "", "AI workspace display name") + utils.AddStringFlag(applyCmd, utils.FlagPlatform, &applyPlatform, "", "Platform name") + utils.AddStringFlag(applyCmd, utils.FlagOutput, &applyOutput, "", "Output format: \"json\" prints the full server response (default: summary)") + applyCmd.Flags().BoolVar(&applyInsecure, "insecure", false, "Skip TLS certificate verification") +} + +func runApplyCommand() error { + projectRoot, wsConfig, err := resolveProjectAIWorkspace(applyProjectDir) + if err != nil { + return err + } + + artifact, err := loadAIWorkspaceArtifact(projectRoot, wsConfig) + if err != nil { + return fmt.Errorf("AI workspace validation failed for %q: %w", wsConfig.Name, err) + } + + body, projectID, err := marshalAIWorkspacePayload(artifact, applyProjectID) + if err != nil { + return err + } + + // Resolve ENV_CLI_* placeholders carried from metadata.yaml/runtime.yaml + // into the generated payload before it is sent. + body, err = resolveEnvPlaceholders(body, projectRoot, applyEnvFile) + if err != nil { + return err + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, applyName, applyPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, applyInsecure) + resp, err := client.PostJSON(aiWorkspaceCreatePath(artifact.BaseKind), body) + if err != nil { + return aiworkspace.WrapRequestError(fmt.Sprintf("apply %s", artifact.BaseKind), err, applyInsecure) + } + + return aiworkspace.PrintApplyResult(resp, applyOutput, artifact.BaseKind, "applied", artifact.ResourceName, projectID) +} diff --git a/cli/src/cmd/aiworkspace/build.go b/cli/src/cmd/aiworkspace/build.go index 9f48fef6c3..96ec86f099 100644 --- a/cli/src/cmd/aiworkspace/build.go +++ b/cli/src/cmd/aiworkspace/build.go @@ -53,7 +53,7 @@ var buildCmd = &cobra.Command{ "(or the current directory if not specified). The command reads the ai-workspace " + "configuration in .api-platform/config.yaml and checks that its metadata.yaml, runtime.yaml " + "and definition.yaml are present, that the metadata and runtime kinds align, and that the " + - "resource name matches. It does not generate or send any artifact — use `ap ai-workspace push` " + + "resource name matches. It does not generate or send any artifact — use `ap ai-workspace apply` " + "to generate the creation payload and create the artifact.", Example: BuildCmdExample, Run: func(cmd *cobra.Command, args []string) { @@ -105,7 +105,7 @@ func normalizeAIWorkspaceProjectConfig(config *project.AIWorkspaceConfig) { // artifact, loaded from metadata.yaml, runtime.yaml and definition.yaml. It is // produced by loadAIWorkspaceArtifact (which validates but does not generate a // payload) and consumed by buildPayload (which generates the creation payload). -// The split lets `build` validate only, while `push`/`edit` validate then +// The split lets `build` validate only, while `apply`/`edit` validate then // generate and send. type aiWorkspaceArtifact struct { ConfigName string @@ -120,7 +120,7 @@ type aiWorkspaceArtifact struct { // resolveProjectAIWorkspace resolves the project root from projectDir, loads the // project config, ensures a single ai-workspace configuration exists (creating a // default and persisting it when absent), normalizes it, and returns the project -// root and the ai-workspace config entry. Shared by build, push and edit. +// root and the ai-workspace config entry. Shared by build, apply and edit. func resolveProjectAIWorkspace(projectDir string) (string, *project.AIWorkspaceConfig, error) { if strings.TrimSpace(projectDir) == "" { projectDir = "." diff --git a/cli/src/cmd/aiworkspace/llmprovider/root.go b/cli/src/cmd/aiworkspace/llmprovider/root.go index d9181b0889..c5d58f25e7 100644 --- a/cli/src/cmd/aiworkspace/llmprovider/root.go +++ b/cli/src/cmd/aiworkspace/llmprovider/root.go @@ -28,7 +28,7 @@ const ( ap ai-workspace llm-provider list # Create or update a provider from a project with: -# ap ai-workspace push / ap ai-workspace edit` +# ap ai-workspace apply / ap ai-workspace edit` ) // LLMProviderCmd is the parent command for LLM provider operations. diff --git a/cli/src/cmd/aiworkspace/llmproxy/root.go b/cli/src/cmd/aiworkspace/llmproxy/root.go index e2ae41b510..90293199d7 100644 --- a/cli/src/cmd/aiworkspace/llmproxy/root.go +++ b/cli/src/cmd/aiworkspace/llmproxy/root.go @@ -28,7 +28,7 @@ const ( ap ai-workspace app-llm-proxy list --project-id # Create or update a proxy from a project with: -# ap ai-workspace push --project-id +# ap ai-workspace apply --project-id # ap ai-workspace edit --project-id ` ) diff --git a/cli/src/cmd/aiworkspace/mcpproxy/root.go b/cli/src/cmd/aiworkspace/mcpproxy/root.go index abd29f499f..a06cac9d3a 100644 --- a/cli/src/cmd/aiworkspace/mcpproxy/root.go +++ b/cli/src/cmd/aiworkspace/mcpproxy/root.go @@ -28,7 +28,7 @@ const ( ap ai-workspace mcp-proxy list --project-id # Create or update an MCP proxy from a project with: -# ap ai-workspace push --project-id +# ap ai-workspace apply --project-id # ap ai-workspace edit --project-id ` ) diff --git a/cli/src/cmd/aiworkspace/push.go b/cli/src/cmd/aiworkspace/push.go deleted file mode 100644 index ceb4bf6a48..0000000000 --- a/cli/src/cmd/aiworkspace/push.go +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package aiws - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" - "github.com/wso2/api-platform/cli/internal/aiworkspace" - "github.com/wso2/api-platform/cli/internal/config" - "github.com/wso2/api-platform/cli/utils" -) - -const ( - PushCmdLiteral = "push" - PushCmdExample = `# Generate and push the AI workspace artifact from the current project -ap ai-workspace push - -# Push a proxy or MCP artifact (--project-id is required for those kinds) -ap ai-workspace push --project-id - -# Resolve ENV_CLI_* placeholders from a specific env file (defaults to .env in the project root) -ap ai-workspace push --env-file ./secrets.env - -# Push from a specific project directory using a specific AI workspace -ap ai-workspace push -f /path/to/project --project-id --display-name my-workspace --platform eu` -) - -var ( - pushProjectDir string - pushProjectID string - pushEnvFile string - pushName string - pushPlatform string - pushInsecure bool - pushOutput string -) - -var pushCmd = &cobra.Command{ - Use: PushCmdLiteral, - Short: "Generate and push an AI workspace artifact", - Long: "Generate the creation payload from the project's metadata.yaml, runtime.yaml and definition.yaml, " + - "then create the artifact on the WSO2 API Platform AI workspace. The target endpoint is selected by the " + - "artifact kind declared in the project (LlmProvider, LlmProxy, Mcp). For the LlmProxy and Mcp kinds " + - "--project-id is required.", - Example: PushCmdExample, - Run: func(cmd *cobra.Command, args []string) { - if err := runPushCommand(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - }, -} - -func init() { - utils.AddStringFlag(pushCmd, utils.FlagFile, &pushProjectDir, "", "Path to the project directory (defaults to current directory)") - utils.AddStringFlag(pushCmd, utils.FlagProjectID, &pushProjectID, "", "Project ID (required for LlmProxy and Mcp kinds)") - utils.AddStringFlag(pushCmd, utils.FlagEnvFile, &pushEnvFile, "", "Path to an env file resolving ENV_CLI_* placeholders (defaults to .env in the project root)") - utils.AddStringFlag(pushCmd, utils.FlagName, &pushName, "", "AI workspace display name") - utils.AddStringFlag(pushCmd, utils.FlagPlatform, &pushPlatform, "", "Platform name") - utils.AddStringFlag(pushCmd, utils.FlagOutput, &pushOutput, "", "Output format: \"json\" prints the full server response (default: summary)") - pushCmd.Flags().BoolVar(&pushInsecure, "insecure", false, "Skip TLS certificate verification") -} - -func runPushCommand() error { - projectRoot, wsConfig, err := resolveProjectAIWorkspace(pushProjectDir) - if err != nil { - return err - } - - artifact, err := loadAIWorkspaceArtifact(projectRoot, wsConfig) - if err != nil { - return fmt.Errorf("AI workspace validation failed for %q: %w", wsConfig.Name, err) - } - - body, projectID, err := marshalAIWorkspacePayload(artifact, pushProjectID) - if err != nil { - return err - } - - // Resolve ENV_CLI_* placeholders carried from metadata.yaml/runtime.yaml - // into the generated payload before it is sent. - body, err = resolveEnvPlaceholders(body, projectRoot, pushEnvFile) - if err != nil { - return err - } - - cfg, err := config.LoadConfig() - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, pushName, pushPlatform) - if err != nil { - return err - } - - client := aiworkspace.NewClientWithOptions(aiWorkspace, pushInsecure) - resp, err := client.PostJSON(aiWorkspaceCreatePath(artifact.BaseKind), body) - if err != nil { - return aiworkspace.WrapRequestError(fmt.Sprintf("push %s", artifact.BaseKind), err, pushInsecure) - } - - return aiworkspace.PrintApplyResult(resp, pushOutput, artifact.BaseKind, "applied", artifact.ResourceName, projectID) -} diff --git a/cli/src/cmd/aiworkspace/root.go b/cli/src/cmd/aiworkspace/root.go index cc4b49d506..900c706a5c 100644 --- a/cli/src/cmd/aiworkspace/root.go +++ b/cli/src/cmd/aiworkspace/root.go @@ -20,9 +20,9 @@ package aiws import ( "github.com/spf13/cobra" - "github.com/wso2/api-platform/cli/cmd/aiws/llmprovider" - "github.com/wso2/api-platform/cli/cmd/aiws/llmproxy" - "github.com/wso2/api-platform/cli/cmd/aiws/mcpproxy" + "github.com/wso2/api-platform/cli/cmd/aiworkspace/llmprovider" + "github.com/wso2/api-platform/cli/cmd/aiworkspace/llmproxy" + "github.com/wso2/api-platform/cli/cmd/aiworkspace/mcpproxy" ) const ( @@ -48,7 +48,7 @@ func init() { AiWSCmd.AddCommand(useCmd) AiWSCmd.AddCommand(currentCmd) AiWSCmd.AddCommand(buildCmd) - AiWSCmd.AddCommand(pushCmd) + AiWSCmd.AddCommand(applyCmd) AiWSCmd.AddCommand(editCmd) AiWSCmd.AddCommand(llmprovider.LLMProviderCmd) AiWSCmd.AddCommand(llmproxy.LLMProxyCmd) diff --git a/cli/src/cmd/root.go b/cli/src/cmd/root.go index 2c05cbdec5..ec3350a454 100644 --- a/cli/src/cmd/root.go +++ b/cli/src/cmd/root.go @@ -23,7 +23,7 @@ import ( "os" "github.com/spf13/cobra" - "github.com/wso2/api-platform/cli/cmd/aiws" + "github.com/wso2/api-platform/cli/cmd/aiworkspace" "github.com/wso2/api-platform/cli/cmd/devportal" "github.com/wso2/api-platform/cli/cmd/gateway" "github.com/wso2/api-platform/cli/cmd/platform" diff --git a/docs/cli/README.md b/docs/cli/README.md index 2c580cbe47..06f6505f7a 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -10,4 +10,4 @@ | [End-to-End Workflow](end-to-end-workflow.md) | The full flow with a diagram: create a project → deploy to the gateway → build and publish to the Developer Portal or an AI Workspace | | [CLI Reference](reference.md) | Full reference for all `ap gateway` sub-commands (add, list, apply, build, MCP, and more) | | [Customizing Gateway Policies](customizing-gateway-policies.md) | Build custom gateway images with local or PolicyHub policies using `ap gateway image build` | -| [AI-Workspace CLI Reference](ai-workspace/README.md) | Manage AI-Workspace connections (`add`, `list`, `use`, `current`, `remove`), validate a project artifact with `ap ai-workspace build`, and create/update artifacts with `ap ai-workspace push`/`edit` | +| [AI-Workspace CLI Reference](ai-workspace/README.md) | Manage AI-Workspace connections (`add`, `list`, `use`, `current`, `remove`), validate a project artifact with `ap ai-workspace build`, and create/update artifacts with `ap ai-workspace apply`/`edit` | diff --git a/docs/cli/ai-workspace/README.md b/docs/cli/ai-workspace/README.md index d7a0b1d2e9..d3f84f92e2 100644 --- a/docs/cli/ai-workspace/README.md +++ b/docs/cli/ai-workspace/README.md @@ -6,7 +6,7 @@ Available command group: - `ap ai-workspace` -The `add`, `list`, `remove`, `use`, and `current` commands manage AI-Workspace **server connections** stored in the CLI config file (the same per-platform config used by `ap gateway` and `ap devportal`). The `build`, `push`, and `edit` commands are different: they work inside an **API project**. `build` **validates** the project's artifact; `push`/`edit` **generate** the creation payload from the project's artifacts and create/update the artifact on the server (the endpoint is chosen by the artifact kind). +The `add`, `list`, `remove`, `use`, and `current` commands manage AI-Workspace **server connections** stored in the CLI config file (the same per-platform config used by `ap gateway` and `ap devportal`). The `build`, `apply`, and `edit` commands are different: they work inside an **API project**. `build` **validates** the project's artifact; `apply`/`edit` **generate** the creation payload from the project's artifacts and create/update the artifact on the server (the endpoint is chosen by the artifact kind). ## Prerequisites @@ -170,7 +170,7 @@ ai-workspace: definition: ./definition.yaml # OpenAPI spec, required for all kinds ``` -Validation (the same checks `push` and `edit` run before sending): +Validation (the same checks `apply` and `edit` run before sending): - Resolves `metadata`, `runtime`, and `definition` relative to the entry's `portalRoot` (defaults: `./metadata.yaml`, `./runtime.yaml`, `./definition.yaml`; `portalRoot` defaults to `.`, the project root). - Requires `metadata.yaml` and `runtime.yaml` to exist. @@ -183,7 +183,7 @@ All resolved paths are constrained to the project directory; a path that escapes #### Associating gateways (`metadata.yaml`) -Optionally list the gateways the artifact can be deployed to, with per-gateway configuration overrides, in an `associatedGateways` section **under `spec`** in `metadata.yaml`. This applies to all artifact kinds (`LlmProxyMetadata`, `LlmProviderMetadata`, `McpMetadata`). Each entry is keyed by the gateway `id`. `push`/`edit` extract this list from `spec.associatedGateways` and copy it into the generated payload verbatim (entries without an `id` are dropped; the field is omitted entirely when absent): +Optionally list the gateways the artifact can be deployed to, with per-gateway configuration overrides, in an `associatedGateways` section **under `spec`** in `metadata.yaml`. This applies to all artifact kinds (`LlmProxyMetadata`, `LlmProviderMetadata`, `McpMetadata`). Each entry is keyed by the gateway `id`. `apply`/`edit` extract this list from `spec.associatedGateways` and copy it into the generated payload verbatim (entries without an `id` are dropped; the field is omitted entirely when absent): ```yaml # metadata.yaml @@ -201,18 +201,18 @@ spec: `configurations` is a free-form object — the supported keys depend on the artifact type. -## `ap ai-workspace push` / `ap ai-workspace edit` +## `ap ai-workspace apply` / `ap ai-workspace edit` -These commands run the same validation as `build`, then **generate** the creation payload from the project artifacts and send it to the AI workspace. `push` **creates** the artifact (`POST`); `edit` **updates** an existing one (`PUT /{resource}/{id}`, where `id` is `metadata.name`). Both live at the root of `ap ai-workspace` (not under a per-kind group) and select the endpoint from the artifact **kind**: +These commands run the same validation as `build`, then **generate** the creation payload from the project artifacts and send it to the AI workspace. `apply` **creates** the artifact (`POST`); `edit` **updates** an existing one (`PUT /{resource}/{id}`, where `id` is `metadata.name`). Both live at the root of `ap ai-workspace` (not under a per-kind group) and select the endpoint from the artifact **kind**: -| Kind | `push` endpoint | `edit` endpoint | +| Kind | `apply` endpoint | `edit` endpoint | | --- | --- | --- | | `LlmProvider` | `POST /llm-providers` | `PUT /llm-providers/{id}` | | `LlmProxy` | `POST /llm-proxies` | `PUT /llm-proxies/{id}` | | `Mcp` | `POST /mcp-proxies` | `PUT /mcp-proxies/{id}` | ```shell -ap ai-workspace push [-f ] [--project-id ] [--env-file ] [--display-name ] [--platform ] [--insecure] [-o json] +ap ai-workspace apply [-f ] [--project-id ] [--env-file ] [--display-name ] [--platform ] [--insecure] [-o json] ap ai-workspace edit [-f ] [--project-id ] [--env-file ] [--display-name ] [--platform ] [--insecure] [-o json] ``` @@ -220,15 +220,15 @@ Examples: ```shell # Create/update a provider (no project scoping) -ap ai-workspace push +ap ai-workspace apply ap ai-workspace edit # Create/update a proxy or MCP proxy (project-scoped) -ap ai-workspace push --project-id +ap ai-workspace apply --project-id ap ai-workspace edit --project-id # Resolve ENV_CLI_* placeholders from a specific env file -ap ai-workspace push --env-file ./secrets.env +ap ai-workspace apply --env-file ./secrets.env ``` Notes: @@ -241,18 +241,18 @@ Notes: ### Environment variable placeholders (`ENV_CLI_*`) -`metadata.yaml` and `runtime.yaml` may reference **environment variables** for specific field values using the `ENV_CLI_` prefix. This lets you keep values that change between environments — upstream URLs, hosts, project IDs, model names, etc. — out of the project files and supply them at push time. Supported forms: `${ENV_CLI_NAME}`, `$ENV_CLI_NAME`, or a bare `ENV_CLI_NAME` token. +`metadata.yaml` and `runtime.yaml` may reference **environment variables** for specific field values using the `ENV_CLI_` prefix. This lets you keep values that change between environments — upstream URLs, hosts, project IDs, model names, etc. — out of the project files and supply them at apply time. Supported forms: `${ENV_CLI_NAME}`, `$ENV_CLI_NAME`, or a bare `ENV_CLI_NAME` token. > **This is for environment-specific configuration values, not secrets.** The resolved value is substituted into the artifact and sent to the server (it travels in the request body and is stored in the created artifact). Do **not** use it for API keys, tokens, or other secrets. Secrets should be managed server-side and referenced with the platform's `{{ secret "name" }}` placeholders (resolved by the server from its encrypted secret store), which the CLI leaves untouched. ```yaml -# runtime.yaml — a per-environment upstream URL supplied at push time +# runtime.yaml — a per-environment upstream URL supplied at apply time spec: upstream: url: ${ENV_CLI_UPSTREAM_URL} ``` -`push`/`edit` resolve the placeholders in the **generated payload** just before it is sent, looking up each variable in this order: +`apply`/`edit` resolve the placeholders in the **generated payload** just before it is sent, looking up each variable in this order: 1. **`--env-file `** — when given, values come from that env file (a missing file is an error). 2. **`.env` in the project root** — used by default when `--env-file` is not given. @@ -260,7 +260,7 @@ spec: The env file format is one `KEY=VALUE` per line; blank lines and `#` comments are ignored, an `export ` prefix is allowed, and single/double quotes around the value are stripped. -**Push/edit fail if a referenced variable has no value** at push time — the command errors and names every unresolved variable, and nothing is sent to the server. Define the missing variables in an env file (`--env-file` or the project's `.env`) or in the environment and retry. `build` does not resolve placeholders — it only validates the project files. +**Apply/edit fail if a referenced variable has no value** at apply time — the command errors and names every unresolved variable, and nothing is sent to the server. Define the missing variables in an env file (`--env-file` or the project's `.env`) or in the environment and retry. `build` does not resolve placeholders — it only validates the project files. ### Generated payload @@ -282,7 +282,7 @@ The payload shape is selected by kind. | `readOnly` | always `false` | | `openapi` | content of `definition.yaml` (**required**) | | `associatedGateways[]` (`id`, `configurations`) | `metadata.yaml` → `spec.associatedGateways` (omitted when absent) | -| `projectId` | intentionally omitted (injected by `push`/`edit` via `--project-id`) | +| `projectId` | intentionally omitted (injected by `apply`/`edit` via `--project-id`) | #### `LlmProvider` diff --git a/docs/cli/end-to-end-workflow.md b/docs/cli/end-to-end-workflow.md index 5020c43410..5a94f44d27 100644 --- a/docs/cli/end-to-end-workflow.md +++ b/docs/cli/end-to-end-workflow.md @@ -24,7 +24,7 @@ flowchart LR subgraph AW["AI Workspace"] direction LR - I["3b · Validate
ap ai-workspace build"] --> J["4b · Push
ap ai-workspace push"] + I["3b · Validate
ap ai-workspace build"] --> J["4b · Apply
ap ai-workspace apply"] end E -->|REST API| F @@ -90,18 +90,18 @@ ap devportal rest-api publish -f build/devportal.zip --org ```shell ap ai-workspace build # validate the project's artifact -ap ai-workspace push --project-id # generate the payload and create the artifact +ap ai-workspace apply --project-id # generate the payload and create the artifact # (--project-id is required for LlmProxy/Mcp kinds, not for LlmProvider) # to update an existing artifact instead of creating: ap ai-workspace edit --project-id # the endpoint is chosen by the artifact kind; the organization comes from the auth token — no --org flag ``` -`ap ai-workspace build` reads the ai-workspace entry in `.api-platform/config.yaml` and **validates** the artifact (files present, metadata/runtime kinds align, name matches). `ap ai-workspace push`/`edit` run the same validation, then generate the creation payload (folding the OpenAPI spec from `definition.yaml` into it) and create/update the artifact on the server. +`ap ai-workspace build` reads the ai-workspace entry in `.api-platform/config.yaml` and **validates** the artifact (files present, metadata/runtime kinds align, name matches). `ap ai-workspace apply`/`edit` run the same validation, then generate the creation payload (folding the OpenAPI spec from `definition.yaml` into it) and create/update the artifact on the server. ## Notes -- `ap devportal gen`, `ap devportal build`, and `ap ai-workspace build`/`push`/`edit` all operate on an API project (they require `.api-platform/config.yaml`). +- `ap devportal gen`, `ap devportal build`, and `ap ai-workspace build`/`apply`/`edit` all operate on an API project (they require `.api-platform/config.yaml`). - Developer Portal is two stages: `gen` **generates** the editable artifact source under `./devportal`, then `build` **packages** it into `build/devportal.zip`. -- AI Workspace's `build` only **validates** — it writes nothing. The creation payload is generated in-memory by `push`/`edit` at publish time (no build artifact is written to `build/`). -- `--org` on the publish/push commands is the target organization in the Developer Portal / AI Workspace. +- AI Workspace's `build` only **validates** — it writes nothing. The creation payload is generated in-memory by `apply`/`edit` at publish time (no build artifact is written to `build/`). +- `--org` on the publish/apply commands is the target organization in the Developer Portal / AI Workspace. - Add `--insecure` to any portal/gateway command when talking to a local or self-signed HTTPS endpoint.