Skip to content
44 changes: 32 additions & 12 deletions pkg/cli/add_interactive_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func (c *AddInteractiveConfig) selectAIEngineAndKey() error {
// If engine is already overridden, skip selection
if c.EngineOverride != "" {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Using coding agent: "+c.EngineOverride))
return c.configureEngineAPISecret(c.EngineOverride)
return c.selectEngineAuthMethod(c.EngineOverride)
}

// Inform user if workflow specifies an engine
Expand Down Expand Up @@ -62,7 +62,7 @@ func (c *AddInteractiveConfig) selectAIEngineAndKey() error {
c.EngineOverride = selectedEngine
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Selected engine: "+selectedEngine))

return c.configureEngineAPISecret(selectedEngine)
return c.selectEngineAuthMethod(selectedEngine)
}

func (c *AddInteractiveConfig) getWorkflowSpecifiedEngine() string {
Expand Down Expand Up @@ -145,7 +145,31 @@ func prioritizeEngineOption(engineOptions []huh.Option[string], defaultEngine st
}
}

// selectEngineAuthMethod prompts for engine-specific authentication method choices
// (for example, Copilot org billing vs. a personal access token) that only affect
// generated workflow content and have no remote repository side effects. This runs
// during engine selection, before the user has chosen between the PR and local-write
// paths. Collecting and uploading the actual secret value is deferred to
// configureEngineAPISecret, which must only run after the user commits to the PR
// path and the working directory has been confirmed clean.
func (c *AddInteractiveConfig) selectEngineAuthMethod(engine string) error {
// If --no-secret flag is set, skip auth-method selection entirely.
if c.SkipSecret {
return nil
}

// For Copilot, ask the user whether to use copilot-requests (org billing) or a PAT.
// Only prompt when an interactive context is available (wizard path); default to PAT otherwise.
if engine == string(constants.CopilotEngine) && c.Ctx != nil {
return c.selectCopilotAuthMethod()
}

return nil
}

// configureEngineAPISecret collects the API key for the selected engine using the unified engine secrets functions
// and uploads it to the repository. This has remote side effects and must only be called after the user has
// chosen to create a PR and the working directory has been confirmed clean.
func (c *AddInteractiveConfig) configureEngineAPISecret(engine string) error {
addInteractiveLog.Printf("Collecting API key for engine: %s", engine)

Expand All @@ -162,15 +186,11 @@ func (c *AddInteractiveConfig) configureEngineAPISecret(engine string) error {
return nil
}

// For Copilot, ask the user whether to use copilot-requests (org billing) or a PAT.
// Only prompt when an interactive context is available (wizard path); default to PAT otherwise.
if engine == string(constants.CopilotEngine) && c.Ctx != nil {
if err := c.selectCopilotAuthMethod(); err != nil {
return err
}
if c.UseCopilotRequests {
return nil
}
// The Copilot auth-method choice (org billing vs. PAT) was already made in
// selectEngineAuthMethod during engine selection. If the user chose org billing,
// no secret needs to be collected or uploaded.
if engine == string(constants.CopilotEngine) && c.UseCopilotRequests {
return nil
}

// If user doesn't have write access, skip secrets configuration.
Expand Down Expand Up @@ -203,7 +223,7 @@ func (c *AddInteractiveConfig) configureEngineAPISecret(engine string) error {
}

// Update existingSecrets to reflect that the secret was uploaded
// This prevents duplicate secret uploads in createWorkflowPRAndConfigureSecret later
// This prevents duplicate secret uploads in createWorkflowChangesAndConfigureSecret later
opt := constants.GetEngineOption(engine)
if opt != nil {
c.existingSecrets[opt.SecretName] = struct{}{}
Expand Down
35 changes: 23 additions & 12 deletions pkg/cli/add_interactive_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ const (
mergeActionExit mergeAction = "exit"
)

// createWorkflowPRAndConfigureSecret creates the PR, merges it, and adds the secret
func (c *AddInteractiveConfig) createWorkflowPRAndConfigureSecret(ctx context.Context, workflowFiles, initFiles []string, secretName, secretValue string) error {
// createWorkflowChangesAndConfigureSecret writes the workflows, optionally creates and merges a PR, and adds the secret.
func (c *AddInteractiveConfig) createWorkflowChangesAndConfigureSecret(ctx context.Context, workflowFiles, initFiles []string, secretName, secretValue string, createPR bool) error {
addInteractiveLog.Print("Applying changes")

fmt.Fprintln(os.Stderr, "")

// Add the workflow using existing implementation with --create-pull-request
// Add the workflow using the existing implementation.
// Pass the resolved workflows to avoid re-fetching them
// Pass Quiet=true to suppress detailed output (already shown earlier in interactive mode)
// This returns the result including PR number and HasWorkflowDispatch
Expand All @@ -49,7 +49,7 @@ func (c *AddInteractiveConfig) createWorkflowPRAndConfigureSecret(ctx context.Co
Name: "",
Force: false,
AppendText: c.AppendText,
CreatePR: true,
CreatePR: createPR,
NoGitattributes: c.NoGitattributes,
WorkflowDir: c.WorkflowDir,
NoStopAfter: c.NoStopAfter,
Expand All @@ -64,6 +64,11 @@ func (c *AddInteractiveConfig) createWorkflowPRAndConfigureSecret(ctx context.Co
}
c.addResult = result

if !createPR {
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Workflow files written locally. No pull request was created."))
return nil
}

if err := c.ensurePullRequestMerged(result.PRNumber, result.PRURL); err != nil {
return err
}
Expand Down Expand Up @@ -298,16 +303,22 @@ func (c *AddInteractiveConfig) updateLocalBranch() error {
return nil
}

// checkCleanWorkingDirectory verifies the working directory has no uncommitted changes.
// This is checked early in the interactive flow to avoid failing later during PR creation.
func (c *AddInteractiveConfig) checkCleanWorkingDirectory() error {
addInteractiveLog.Print("Checking working directory is clean")

if err := checkCleanWorkingDirectory(c.Verbose); err != nil {
// checkCleanWorkingDirectoryForPR verifies the working directory had no user changes
// before the wizard began repository initialization. It relies on the cleanliness
// snapshot captured in workingDirDirtyBeforeInit (taken before
// ensureAddRepositoryInitializedWithDetails ran) rather than re-checking git status and
// excluding the wizard's init files. Excluding whole init file paths post-hoc would
// wrongly ignore pre-existing, non-conforming files (e.g. a dirty .gitattributes
// missing a required entry) that ensureAddRepositoryInitializedWithDetails rewrites in
// place, letting the PR path silently overwrite or commit pre-existing user edits.
func (c *AddInteractiveConfig) checkCleanWorkingDirectoryForPR() error {
addInteractiveLog.Print("Checking working directory is clean before PR creation")

if c.workingDirDirtyBeforeInit {
fmt.Fprintln(os.Stderr, console.FormatErrorMessage("Working directory is not clean."))
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "The add wizard creates a pull request which requires a clean working directory.")
fmt.Fprintln(os.Stderr, "Please commit or stash your changes first:")
fmt.Fprintln(os.Stderr, "Creating a pull request requires a clean working directory.")
fmt.Fprintln(os.Stderr, "Please commit or stash your changes first, or choose the local write option:")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, console.FormatCommandMessage(" git stash # Temporarily stash changes"))
fmt.Fprintln(os.Stderr, console.FormatCommandMessage(" git add -A && git commit -m 'wip' # Commit changes"))
Expand Down
128 changes: 97 additions & 31 deletions pkg/cli/add_interactive_orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ type AddInteractiveConfig struct {
// resolvedWorkflows holds the pre-resolved workflow data including descriptions
// This is populated early in the flow by resolveWorkflows()
resolvedWorkflows *ResolvedWorkflows

// workingDirDirtyBeforeInit records whether the working directory already had
// uncommitted changes before any wizard-driven repository initialization ran.
// It is captured once, early in RunAddInteractive, and used by
// checkCleanWorkingDirectoryForPR so that wizard-modified init markers (which may
// rewrite pre-existing, non-conforming files) are never mistaken for a clean tree.
workingDirDirtyBeforeInit bool
}

// RunAddInteractive runs the interactive add workflow
Expand All @@ -88,16 +95,34 @@ func RunAddInteractive(ctx context.Context, config *AddInteractiveConfig) error
return err
}

// Snapshot working directory cleanliness before any wizard-driven repository
// initialization runs. This is used later, only for the PR path, to detect
// pre-existing user changes without mistaking wizard-modified init markers for
// pre-existing dirty state.
pendingChanges, err := hasPendingChanges()
if err != nil {
return err
}
config.workingDirDirtyBeforeInit = pendingChanges

remainingBootstrapProfile := config.getRemainingBootstrapProfile()

filesToAdd, initFiles, secretName, secretValue, err := config.prepareAndConfirmAddInteractive()
filesToAdd, initFiles, secretName, secretValue, createPR, err := config.prepareAndConfirmAddInteractive()
if err != nil {
return err
}

if err := config.createWorkflowPRAndConfigureSecret(ctx, filesToAdd, initFiles, secretName, secretValue); err != nil {
if err := config.createWorkflowChangesAndConfigureSecret(ctx, filesToAdd, initFiles, secretName, secretValue, createPR); err != nil {
return err
}
if !createPR {
// Local writes stop before remote-only follow-up: repository secret updates,
// bootstrap mutations, workflow status polling, and optional dispatch all require
// the workflow changes to be present on GitHub.
printBootstrapConfigTODO(os.Stderr, remainingBootstrapProfile)
config.showLocalWriteInstructions()
return nil
}

if err := config.applyBootstrapConfigIfNeeded(ctx, remainingBootstrapProfile); err != nil {
return err
Expand Down Expand Up @@ -159,46 +184,61 @@ func (c *AddInteractiveConfig) runInitialAddInteractiveChecks() error {
if err := c.checkGitRepository(); err != nil {
return err
}
if err := c.checkCleanWorkingDirectory(); err != nil {
return err
}
if err := c.checkActionsEnabled(); err != nil {
return err
}
return c.checkUserPermissions()
}

func (c *AddInteractiveConfig) prepareAndConfirmAddInteractive() (workflowFiles, initFiles []string, secretName, secretValue string, err error) {
func (c *AddInteractiveConfig) prepareAndConfirmAddInteractive() (workflowFiles, initFiles []string, secretName, secretValue string, createPR bool, err error) {
// selectAIEngineAndKey only selects the engine and, for Copilot, the auth method
// (org billing vs. PAT). It does not prompt for or upload any secret value, since
// that has remote repository side effects and must wait until the user has
// chosen the PR path and the working directory has been confirmed clean.
if err := c.selectAIEngineAndKey(); err != nil {
return nil, nil, "", "", err
return nil, nil, "", "", false, err
}

initFiles, err = ensureAddRepositoryInitializedWithDetails(c.EngineOverride, c.Verbose, c.NoGitattributes)
if err != nil {
return nil, nil, "", "", err
return nil, nil, "", "", false, err
}

workflowFiles, _, err = c.determineFilesToAdd()
if err != nil {
return nil, nil, "", "", err
return nil, nil, "", "", false, err
}

if err := c.selectScheduleFrequency(); err != nil {
return nil, nil, "", "", err
return nil, nil, "", "", false, err
}

createPR, err = c.confirmChanges(workflowFiles, initFiles)
if err != nil {
return nil, nil, "", "", false, err
}
if !createPR {
return workflowFiles, initFiles, "", "", false, nil
}

if err := c.checkCleanWorkingDirectoryForPR(); err != nil {
return nil, nil, "", "", false, err
}

// Secret collection and upload only happen once the user has committed to the
// PR path and the clean-tree check has succeeded.
if err := c.configureEngineAPISecret(c.EngineOverride); err != nil {
return nil, nil, "", "", false, err
}

if c.hasWriteAccess && !c.SkipSecret && !c.UseCopilotRequests {
secretName, secretValue, err = c.resolveEngineApiKeyCredential()
if err != nil {
return nil, nil, "", "", err
return nil, nil, "", "", false, err
}
}

if err := c.confirmChanges(workflowFiles, initFiles, secretName, secretValue); err != nil {
return nil, nil, "", "", err
}

return workflowFiles, initFiles, secretName, secretValue, nil
return workflowFiles, initFiles, secretName, secretValue, createPR, nil
}

// resolveWorkflows resolves workflow specifications by installing repositories,
Expand Down Expand Up @@ -324,8 +364,7 @@ func (c *AddInteractiveConfig) primaryWorkflowName() string {
}

// confirmChanges asks the user to confirm the changes
// secretValue is empty if the secret already exists in the repository
func (c *AddInteractiveConfig) confirmChanges(workflowFiles, initFiles []string, secretName string, secretValue string) error {
func (c *AddInteractiveConfig) confirmChanges(workflowFiles, initFiles []string) (bool, error) {
addInteractiveLog.Print("Confirming changes with user")

fmt.Fprintln(os.Stderr, "")
Expand All @@ -337,29 +376,25 @@ func (c *AddInteractiveConfig) confirmChanges(workflowFiles, initFiles []string,
fmt.Fprintln(os.Stderr, "")
}

confirmed := true // Default to yes
createPR := true // Default to yes
form := console.NewConfirmForm(
huh.NewConfirm().
Title("Do you want to proceed with these changes?").
Description("A pull request will be created with the workflow files").
Title("Do you want to create a pull request with these changes?").
Description("Choose No to write the workflow files locally without creating a pull request").
Affirmative("Yes, create pull request").
Negative("No, cancel").
Value(&confirmed),
Negative("No, write files locally").
Value(&createPR),
)

if err := form.RunWithContext(c.Ctx); err != nil {
return fmt.Errorf("confirmation failed: %w", err)
}

if !confirmed {
fmt.Fprintln(os.Stderr, "Operation cancelled.")
return errors.New("user cancelled the operation")
return false, fmt.Errorf("confirmation failed: %w", err)
}

return nil
return createPR, nil
}

// showFinalInstructions shows final instructions to the user
// showFinalInstructions shows final instructions to the user after a PR was created
// and the workflow files are live on GitHub.
func (c *AddInteractiveConfig) showFinalInstructions() {
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
Expand All @@ -382,3 +417,34 @@ func (c *AddInteractiveConfig) showFinalInstructions() {
fmt.Fprintln(os.Stderr, "Learn more at: https://github.github.com/gh-aw/")
fmt.Fprintln(os.Stderr, "")
}

// showLocalWriteInstructions shows final instructions to the user when workflow files
// were written locally without creating a PR. Unlike showFinalInstructions, this does
// not claim the workflow is already running or recommend remote status/run commands,
// since the files only exist in the local checkout and have not been pushed.
func (c *AddInteractiveConfig) showLocalWriteInstructions() {
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("🎉 Files written locally!"))
fmt.Fprintln(os.Stderr, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Fprintln(os.Stderr, "")

// Show summary with workflow name(s)
if c.resolvedWorkflows != nil && len(c.resolvedWorkflows.Workflows) > 0 {
wf := c.resolvedWorkflows.Workflows[0]
fmt.Fprintf(os.Stderr, "The workflow '%s' has been written to your local checkout. No pull request was created.\n", wf.Spec.WorkflowName)
c.showWorkflowDescriptions()
}

fmt.Fprintln(os.Stderr, "Commit and push the new files before the workflow can run on GitHub:")
fmt.Fprintln(os.Stderr, console.FormatCommandMessage(" git add -A && git commit -m 'Add agentic workflow'"))
fmt.Fprintln(os.Stderr, console.FormatCommandMessage(" git push"))
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Once pushed, these commands will work against the remote repository:")
fmt.Fprintln(os.Stderr, console.FormatCommandMessage(fmt.Sprintf(" %s status # Check workflow status", string(constants.CLIExtensionPrefix))))
fmt.Fprintln(os.Stderr, console.FormatCommandMessage(fmt.Sprintf(" %s run <workflow> # Trigger a workflow", string(constants.CLIExtensionPrefix))))
fmt.Fprintln(os.Stderr, console.FormatCommandMessage(fmt.Sprintf(" %s logs # View workflow logs", string(constants.CLIExtensionPrefix))))
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Learn more at: https://github.github.com/gh-aw/")
fmt.Fprintln(os.Stderr, "")
}
Loading