diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 60220eb8b0..f0b47992b0 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -720,6 +720,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all projectCmd := cli.NewProjectCommand() checksCmd := cli.NewChecksCommand() validateCmd := cli.NewValidateCommand(validateEngine) + domainsCmd := cli.NewDomainsCommand() // Assign commands to groups // Setup Commands @@ -739,6 +740,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all statusCmd.GroupID = "development" listCmd.GroupID = "development" fixCmd.GroupID = "development" + domainsCmd.GroupID = "development" // Execution Commands runCmd.GroupID = "execution" @@ -790,6 +792,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all rootCmd.AddCommand(completionCmd) rootCmd.AddCommand(hashCmd) rootCmd.AddCommand(projectCmd) + rootCmd.AddCommand(domainsCmd) // Fix help flag descriptions for all subcommands to be consistent with the // root command ("Show help for gh aw" vs the Cobra default "help for [cmd]"). diff --git a/pkg/cli/domains_command.go b/pkg/cli/domains_command.go new file mode 100644 index 0000000000..1b7a4f61a1 --- /dev/null +++ b/pkg/cli/domains_command.go @@ -0,0 +1,275 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/parser" + "github.com/github/gh-aw/pkg/workflow" + "github.com/spf13/cobra" +) + +var domainsCommandLog = logger.New("cli:domains_command") + +// WorkflowDomainsSummary represents a workflow's domain configuration for list output +type WorkflowDomainsSummary struct { + Workflow string `json:"workflow" console:"header:Workflow"` + Engine string `json:"engine" console:"header:Engine"` + Allowed int `json:"allowed" console:"header:Allowed"` + Blocked int `json:"blocked" console:"header:Blocked"` +} + +// WorkflowDomainsDetail represents the detailed domain configuration for a single workflow +type WorkflowDomainsDetail struct { + Workflow string `json:"workflow"` + Engine string `json:"engine"` + AllowedDomains []string `json:"allowed_domains"` + BlockedDomains []string `json:"blocked_domains"` +} + +// DomainItem represents a single domain entry for tabular display +type DomainItem struct { + Domain string `json:"domain" console:"header:Domain"` + Ecosystem string `json:"ecosystem" console:"header:Ecosystem"` + Status string `json:"status" console:"header:Status"` +} + +// NewDomainsCommand creates the domains command +func NewDomainsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "domains [workflow]", + Short: "List network domains configured in agentic workflows", + Long: `List network domains configured in agentic workflows. + +When no workflow is specified, lists all workflows with a summary of their allowed +and blocked domain counts. + +When a workflow ID or file is specified, lists all effective allowed and blocked +domains for that workflow, including domains expanded from ecosystem identifiers +(e.g. "node", "python", "github") and engine defaults. + +The workflow argument can be: +- A workflow ID (basename without .md extension, e.g., "weekly-research") +- A file path (e.g., "weekly-research.md" or ".github/workflows/weekly-research.md") + +Examples: + ` + string(constants.CLIExtensionPrefix) + ` domains # List all workflows with domain counts + ` + string(constants.CLIExtensionPrefix) + ` domains weekly-research # List domains for weekly-research workflow + ` + string(constants.CLIExtensionPrefix) + ` domains --json # Output summary in JSON format + ` + string(constants.CLIExtensionPrefix) + ` domains weekly-research --json # Output workflow domains in JSON format`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + jsonFlag, _ := cmd.Flags().GetBool("json") + + if len(args) == 1 { + return RunWorkflowDomains(args[0], jsonFlag) + } + return RunListDomains(jsonFlag) + }, + } + + addJSONFlag(cmd) + cmd.ValidArgsFunction = CompleteWorkflowNames + + return cmd +} + +// RunListDomains lists all workflows with their domain configuration summary +func RunListDomains(jsonOutput bool) error { + domainsCommandLog.Printf("Listing domains for all workflows: jsonOutput=%v", jsonOutput) + + workflowsDir := getWorkflowsDir() + mdFiles, err := getMarkdownWorkflowFiles(workflowsDir) + if err != nil { + fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) + return nil + } + + if len(mdFiles) == 0 { + if jsonOutput { + fmt.Println("[]") + return nil + } + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No workflow files found.")) + return nil + } + + var summaries []WorkflowDomainsSummary + + for _, file := range mdFiles { + name := extractWorkflowNameFromPath(file) + engineID, network, tools, runtimes := extractWorkflowDomainConfig(file) + + allowedDomains := computeAllowedDomains(constants.EngineName(engineID), network, tools, runtimes) + blockedDomains := workflow.GetBlockedDomains(network) + + summaries = append(summaries, WorkflowDomainsSummary{ + Workflow: name, + Engine: engineID, + Allowed: len(allowedDomains), + Blocked: len(blockedDomains), + }) + } + + if jsonOutput { + jsonBytes, err := json.MarshalIndent(summaries, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + count := len(summaries) + if count == 1 { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Found 1 workflow")) + } else { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Found %d workflows", count))) + } + fmt.Fprint(os.Stderr, console.RenderStruct(summaries)) + + return nil +} + +// RunWorkflowDomains lists all effective domains for a specific workflow +func RunWorkflowDomains(workflowArg string, jsonOutput bool) error { + domainsCommandLog.Printf("Listing domains for workflow: %s, jsonOutput=%v", workflowArg, jsonOutput) + + workflowPath, err := ResolveWorkflowPath(workflowArg) + if err != nil { + return err + } + + engineID, network, tools, runtimes := extractWorkflowDomainConfig(workflowPath) + name := extractWorkflowNameFromPath(workflowPath) + + allowedDomains := computeAllowedDomains(constants.EngineName(engineID), network, tools, runtimes) + blockedDomains := workflow.GetBlockedDomains(network) + + if jsonOutput { + detail := WorkflowDomainsDetail{ + Workflow: name, + Engine: engineID, + AllowedDomains: allowedDomains, + BlockedDomains: blockedDomains, + } + jsonBytes, err := json.MarshalIndent(detail, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + // Console output: show domain items grouped by allowed/blocked + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage( + fmt.Sprintf("Network domains for %s (engine: %s)", name, engineID), + )) + + items := buildDomainItems(allowedDomains, blockedDomains) + + if len(items) == 0 { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No domains configured.")) + return nil + } + + // Build table rows + headers := []string{"Domain", "Ecosystem", "Status"} + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{item.Domain, item.Ecosystem, item.Status}) + } + + tableConfig := console.TableConfig{ + Title: "Domains for " + name, + Headers: headers, + Rows: rows, + } + fmt.Fprint(os.Stderr, console.RenderTable(tableConfig)) + + allowedCount := strconv.Itoa(len(allowedDomains)) + blockedCount := strconv.Itoa(len(blockedDomains)) + fmt.Fprintf(os.Stderr, "\n%s allowed, %s blocked\n", allowedCount, blockedCount) + + return nil +} + +// extractWorkflowDomainConfig reads a workflow file and returns its engine ID, +// network permissions, tools, and runtimes configuration. +func extractWorkflowDomainConfig(filePath string) (engineID string, network *workflow.NetworkPermissions, tools map[string]any, runtimes map[string]any) { + content, err := os.ReadFile(filePath) + if err != nil { + domainsCommandLog.Printf("Failed to read workflow file %s: %v", filePath, err) + return "copilot", nil, nil, nil + } + + result, err := parser.ExtractFrontmatterFromContent(string(content)) + if err != nil || result.Frontmatter == nil { + domainsCommandLog.Printf("Failed to parse frontmatter from %s: %v", filePath, err) + return "copilot", nil, nil, nil + } + + // Reuse the existing engine ID extraction helper which handles both string and object formats + engineID = extractEngineIDFromFrontmatter(result.Frontmatter) + + // Parse structured frontmatter config to get NetworkPermissions and runtimes + config, err := workflow.ParseFrontmatterConfig(result.Frontmatter) + if err != nil { + domainsCommandLog.Printf("Failed to parse frontmatter config from %s: %v", filePath, err) + return engineID, nil, nil, nil + } + + // Extract tools map from raw frontmatter (tools is kept as map[string]any) + var toolsMap map[string]any + if toolsRaw, ok := result.Frontmatter["tools"]; ok { + toolsMap, _ = toolsRaw.(map[string]any) + } + + return engineID, config.Network, toolsMap, config.Runtimes +} + +// computeAllowedDomains returns the effective allowed domains for an engine + network config. +// It mirrors the logic used during workflow compilation. +func computeAllowedDomains(engine constants.EngineName, network *workflow.NetworkPermissions, tools map[string]any, runtimes map[string]any) []string { + combined := workflow.GetAllowedDomainsForEngine(engine, network, tools, runtimes) + if combined == "" { + return []string{} + } + // GetAllowedDomainsForEngine returns a comma-separated string; split it + parts := strings.Split(combined, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + if p != "" { + result = append(result, p) + } + } + return result +} + +// buildDomainItems creates a list of DomainItem from allowed and blocked domain slices +func buildDomainItems(allowedDomains, blockedDomains []string) []DomainItem { + items := make([]DomainItem, 0, len(allowedDomains)+len(blockedDomains)) + for _, d := range allowedDomains { + ecosystem := workflow.GetDomainEcosystem(d) + items = append(items, DomainItem{ + Domain: d, + Ecosystem: ecosystem, + Status: "✓ Allowed", + }) + } + for _, d := range blockedDomains { + ecosystem := workflow.GetDomainEcosystem(d) + items = append(items, DomainItem{ + Domain: d, + Ecosystem: ecosystem, + Status: "✗ Blocked", + }) + } + return items +} diff --git a/pkg/cli/domains_command_test.go b/pkg/cli/domains_command_test.go new file mode 100644 index 0000000000..823bd60eae --- /dev/null +++ b/pkg/cli/domains_command_test.go @@ -0,0 +1,360 @@ +//go:build !integration + +package cli + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/github/gh-aw/pkg/workflow" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunListDomains_NoWorkflows(t *testing.T) { + // Change to a temp directory with no workflows + tmpDir := t.TempDir() + originalDir, err := os.Getwd() + require.NoError(t, err, "Failed to get current directory") + err = os.Chdir(tmpDir) + require.NoError(t, err, "Failed to change to temp directory") + defer os.Chdir(originalDir) //nolint:errcheck + + // Create the .github/workflows directory but with no markdown files + err = os.MkdirAll(".github/workflows", 0755) + require.NoError(t, err, "Failed to create workflows directory") + + t.Run("no workflows text output", func(t *testing.T) { + err := RunListDomains(false) + assert.NoError(t, err, "RunListDomains should not error with no workflows") + }) + + t.Run("no workflows JSON output", func(t *testing.T) { + err := RunListDomains(true) + assert.NoError(t, err, "RunListDomains should not error with no workflows in JSON mode") + }) +} + +func TestRunListDomains_WithWorkflow(t *testing.T) { + tmpDir := t.TempDir() + originalDir, err := os.Getwd() + require.NoError(t, err, "Failed to get current directory") + err = os.Chdir(tmpDir) + require.NoError(t, err, "Failed to change to temp directory") + defer os.Chdir(originalDir) //nolint:errcheck + + err = os.MkdirAll(".github/workflows", 0755) + require.NoError(t, err, "Failed to create workflows directory") + + // Write a workflow file with network config + workflowContent := `--- +engine: copilot +network: + allowed: + - github + - node +--- +# Test Workflow +Do something. +` + workflowPath := filepath.Join(".github", "workflows", "test-workflow.md") + err = os.WriteFile(workflowPath, []byte(workflowContent), 0600) + require.NoError(t, err, "Failed to write workflow file") + + t.Run("text output", func(t *testing.T) { + err := RunListDomains(false) + assert.NoError(t, err, "RunListDomains should not error") + }) + + t.Run("JSON output", func(t *testing.T) { + // Capture stdout by redirecting + err := RunListDomains(true) + assert.NoError(t, err, "RunListDomains JSON should not error") + }) +} + +func TestRunWorkflowDomains_JSONOutput(t *testing.T) { + tmpDir := t.TempDir() + originalDir, err := os.Getwd() + require.NoError(t, err, "Failed to get current directory") + err = os.Chdir(tmpDir) + require.NoError(t, err, "Failed to change to temp directory") + defer os.Chdir(originalDir) //nolint:errcheck + + err = os.MkdirAll(".github/workflows", 0755) + require.NoError(t, err, "Failed to create workflows directory") + + workflowContent := `--- +engine: copilot +network: + allowed: + - github + blocked: + - malicious.example.com +--- +# Test +` + workflowPath := filepath.Join(".github", "workflows", "my-workflow.md") + err = os.WriteFile(workflowPath, []byte(workflowContent), 0600) + require.NoError(t, err, "Failed to write workflow file") + + err = RunWorkflowDomains("my-workflow", true) + assert.NoError(t, err, "RunWorkflowDomains JSON should not error") +} + +func TestRunWorkflowDomains_TextOutput(t *testing.T) { + tmpDir := t.TempDir() + originalDir, err := os.Getwd() + require.NoError(t, err, "Failed to get current directory") + err = os.Chdir(tmpDir) + require.NoError(t, err, "Failed to change to temp directory") + defer os.Chdir(originalDir) //nolint:errcheck + + err = os.MkdirAll(".github/workflows", 0755) + require.NoError(t, err, "Failed to create workflows directory") + + workflowContent := `--- +engine: copilot +network: + allowed: + - github +--- +# Test +` + workflowPath := filepath.Join(".github", "workflows", "my-workflow.md") + err = os.WriteFile(workflowPath, []byte(workflowContent), 0600) + require.NoError(t, err, "Failed to write workflow file") + + err = RunWorkflowDomains("my-workflow", false) + assert.NoError(t, err, "RunWorkflowDomains text should not error") +} + +func TestWorkflowDomainsDetail_JSONMarshaling(t *testing.T) { + detail := WorkflowDomainsDetail{ + Workflow: "my-workflow", + Engine: "copilot", + AllowedDomains: []string{"api.github.com", "github.com"}, + BlockedDomains: []string{"malicious.example.com"}, + } + + jsonBytes, err := json.MarshalIndent(detail, "", " ") + require.NoError(t, err, "Should marshal WorkflowDomainsDetail to JSON") + + jsonStr := string(jsonBytes) + assert.Contains(t, jsonStr, `"workflow": "my-workflow"`, "JSON should contain workflow name") + assert.Contains(t, jsonStr, `"engine": "copilot"`, "JSON should contain engine") + assert.Contains(t, jsonStr, `"allowed_domains"`, "JSON should contain allowed_domains key") + assert.Contains(t, jsonStr, `"blocked_domains"`, "JSON should contain blocked_domains key") + assert.Contains(t, jsonStr, `"api.github.com"`, "JSON should contain allowed domain") + assert.Contains(t, jsonStr, `"malicious.example.com"`, "JSON should contain blocked domain") +} + +func TestWorkflowDomainsSummary_JSONMarshaling(t *testing.T) { + summary := WorkflowDomainsSummary{ + Workflow: "my-workflow", + Engine: "copilot", + Allowed: 10, + Blocked: 2, + } + + jsonBytes, err := json.MarshalIndent(summary, "", " ") + require.NoError(t, err, "Should marshal WorkflowDomainsSummary to JSON") + + jsonStr := string(jsonBytes) + assert.Contains(t, jsonStr, `"workflow": "my-workflow"`, "JSON should contain workflow name") + assert.Contains(t, jsonStr, `"engine": "copilot"`, "JSON should contain engine") + assert.Contains(t, jsonStr, `"allowed": 10`, "JSON should contain allowed count") + assert.Contains(t, jsonStr, `"blocked": 2`, "JSON should contain blocked count") +} + +func TestBuildDomainItems(t *testing.T) { + allowed := []string{"api.github.com", "github.com"} + blocked := []string{"malicious.example.com"} + + items := buildDomainItems(allowed, blocked) + require.Len(t, items, 3, "Should have 3 items total") + + // First two should be allowed + assert.Equal(t, "api.github.com", items[0].Domain, "First item should be api.github.com") + assert.Contains(t, items[0].Status, "Allowed", "First item should be allowed") + + assert.Equal(t, "github.com", items[1].Domain, "Second item should be github.com") + assert.Contains(t, items[1].Status, "Allowed", "Second item should be allowed") + + // Last one should be blocked + assert.Equal(t, "malicious.example.com", items[2].Domain, "Third item should be malicious.example.com") + assert.Contains(t, items[2].Status, "Blocked", "Third item should be blocked") +} + +func TestBuildDomainItems_EcosystemAnnotation(t *testing.T) { + allowed := []string{"registry.npmjs.org", "pypi.org"} + items := buildDomainItems(allowed, nil) + + require.Len(t, items, 2, "Should have 2 items") + + // registry.npmjs.org should be in the node ecosystem + assert.Equal(t, "registry.npmjs.org", items[0].Domain, "First domain should be registry.npmjs.org") + assert.Equal(t, "node", items[0].Ecosystem, "registry.npmjs.org should be in node ecosystem") + + // pypi.org should be in the python ecosystem + assert.Equal(t, "pypi.org", items[1].Domain, "Second domain should be pypi.org") + assert.Equal(t, "python", items[1].Ecosystem, "pypi.org should be in python ecosystem") +} + +func TestNewDomainsCommand(t *testing.T) { + cmd := NewDomainsCommand() + assert.NotNil(t, cmd, "NewDomainsCommand should return a command") + assert.Equal(t, "domains [workflow]", cmd.Use, "Command use should be 'domains [workflow]'") + assert.NotEmpty(t, cmd.Short, "Command should have a short description") + assert.NotEmpty(t, cmd.Long, "Command should have a long description") + + // Check --json flag exists + jsonFlag := cmd.Flags().Lookup("json") + assert.NotNil(t, jsonFlag, "Command should have --json flag") + + // Check max 1 argument + err := cmd.Args(cmd, []string{"a", "b"}) + require.Error(t, err, "Command should reject more than 1 argument") + + err = cmd.Args(cmd, []string{}) + require.NoError(t, err, "Command should accept 0 arguments") + + err = cmd.Args(cmd, []string{"workflow-name"}) + require.NoError(t, err, "Command should accept 1 argument") +} + +func TestExtractWorkflowDomainConfig(t *testing.T) { + tmpDir := t.TempDir() + + t.Run("workflow with network config", func(t *testing.T) { + content := `--- +engine: claude +network: + allowed: + - github + - python + blocked: + - bad.example.com +--- +# Test +` + path := filepath.Join(tmpDir, "test.md") + err := os.WriteFile(path, []byte(content), 0600) + require.NoError(t, err, "Failed to write test file") + + engineID, network, _, _ := extractWorkflowDomainConfig(path) + assert.Equal(t, "claude", engineID, "Engine should be claude") + require.NotNil(t, network, "Network should not be nil") + assert.Equal(t, []string{"github", "python"}, network.Allowed, "Allowed should match") + assert.Equal(t, []string{"bad.example.com"}, network.Blocked, "Blocked should match") + }) + + t.Run("workflow without network config defaults to copilot", func(t *testing.T) { + content := `--- +engine: copilot +--- +# Test +` + path := filepath.Join(tmpDir, "no-network.md") + err := os.WriteFile(path, []byte(content), 0600) + require.NoError(t, err, "Failed to write test file") + + engineID, network, _, _ := extractWorkflowDomainConfig(path) + assert.Equal(t, "copilot", engineID, "Engine should be copilot") + assert.Nil(t, network, "Network should be nil when not configured") + }) + + t.Run("nonexistent file defaults to copilot", func(t *testing.T) { + engineID, network, _, _ := extractWorkflowDomainConfig("/nonexistent/file.md") + assert.Equal(t, "copilot", engineID, "Engine should default to copilot") + assert.Nil(t, network, "Network should be nil for nonexistent file") + }) +} + +func TestComputeAllowedDomains(t *testing.T) { + t.Run("copilot engine without network config", func(t *testing.T) { + domains := computeAllowedDomains("copilot", nil, nil, nil) + // Copilot has default domains + assert.NotEmpty(t, domains, "Should have default Copilot domains") + assert.True(t, slices.Contains(domains, "api.github.com"), "Should contain api.github.com") + }) + + t.Run("returns empty for unknown engine with empty network", func(t *testing.T) { + network := &workflow.NetworkPermissions{ + Allowed: []string{}, + } + domains := computeAllowedDomains("custom", network, nil, nil) + assert.Empty(t, domains, "Should return empty for explicit empty allowed list") + }) +} + +func TestRunListDomains_RepoRoot(t *testing.T) { + originalDir, err := os.Getwd() + require.NoError(t, err, "Failed to get current directory") + + repoRoot := filepath.Join(originalDir, "..", "..") + err = os.Chdir(repoRoot) + require.NoError(t, err, "Failed to change to repository root") + defer os.Chdir(originalDir) //nolint:errcheck + + t.Run("JSON output from repo root", func(t *testing.T) { + err := RunListDomains(true) + assert.NoError(t, err, "RunListDomains JSON should not error from repo root") + }) + + t.Run("text output from repo root", func(t *testing.T) { + err := RunListDomains(false) + assert.NoError(t, err, "RunListDomains text should not error from repo root") + }) +} + +func TestRunListDomains_JSONFormat(t *testing.T) { + tmpDir := t.TempDir() + originalDir, err := os.Getwd() + require.NoError(t, err, "Failed to get current directory") + err = os.Chdir(tmpDir) + require.NoError(t, err, "Failed to change to temp directory") + defer os.Chdir(originalDir) //nolint:errcheck + + err = os.MkdirAll(".github/workflows", 0755) + require.NoError(t, err, "Failed to create workflows directory") + + // Write two workflow files + for _, wf := range []struct{ name, engine string }{ + {"wf-a", "copilot"}, + {"wf-b", "claude"}, + } { + content := "---\nengine: " + wf.engine + "\n---\n# Test\n" + path := filepath.Join(".github", "workflows", wf.name+".md") + err = os.WriteFile(path, []byte(content), 0600) + require.NoError(t, err, "Failed to write workflow file") + } + + // Capture JSON by redirecting stdout + oldStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err, "Failed to create pipe") + os.Stdout = w + + err = RunListDomains(true) + require.NoError(t, err, "RunListDomains should not error") + + w.Close() + os.Stdout = oldStdout + + outputBytes, err := io.ReadAll(r) + require.NoError(t, err, "Failed to read pipe output") + r.Close() + + output := string(outputBytes) + assert.NotEmpty(t, output, "JSON output should not be empty") + + var summaries []WorkflowDomainsSummary + err = json.Unmarshal(outputBytes, &summaries) + require.NoError(t, err, "JSON output should be valid JSON array") + assert.Len(t, summaries, 2, "Should have 2 workflow summaries") +} diff --git a/pkg/cli/workflows.go b/pkg/cli/workflows.go index 3bd320b518..4fc716008d 100644 --- a/pkg/cli/workflows.go +++ b/pkg/cli/workflows.go @@ -350,6 +350,22 @@ func extractWorkflowNameFromFile(filePath string) (string, error) { return strings.Join(words, " "), nil } +// extractEngineIDFromFrontmatter extracts the engine ID from a parsed frontmatter map. +// Returns "copilot" as the default if no engine is specified. +func extractEngineIDFromFrontmatter(frontmatter map[string]any) string { + // Use the workflow package's ExtractEngineConfig to handle both string and object formats + compiler := &workflow.Compiler{} + engineSetting, engineConfig := compiler.ExtractEngineConfig(frontmatter) + + if engineConfig != nil && engineConfig.ID != "" { + return engineConfig.ID + } + if engineSetting != "" { + return engineSetting + } + return "copilot" // Default engine +} + // extractEngineIDFromFile extracts the engine ID from a workflow file's frontmatter func extractEngineIDFromFile(filePath string) string { content, err := os.ReadFile(filePath) @@ -363,21 +379,7 @@ func extractEngineIDFromFile(filePath string) string { return "" // Return empty string if frontmatter cannot be parsed } - // Use the workflow package's extractEngineConfig to handle both string and object formats - compiler := &workflow.Compiler{} - engineSetting, engineConfig := compiler.ExtractEngineConfig(result.Frontmatter) - - // If engine is specified, return the ID from the config - if engineConfig != nil && engineConfig.ID != "" { - return engineConfig.ID - } - - // If we have an engine setting string, return it - if engineSetting != "" { - return engineSetting - } - - return "copilot" // Default engine + return extractEngineIDFromFrontmatter(result.Frontmatter) } // normalizeWorkflowID extracts the workflow ID from a workflow identifier.