Skip to content

Commit d54d1a4

Browse files
authored
AI Agent UX Improvements (#322)
* CLI AI UX improvements: - Show required networks in cre templates list output - Add --json flag to cre templates list for machine-parseable output - Show actionable error with missing --rpc-url flags when cre init fails without TTY - Add --non-interactive flag to cre init for CI/CD usage - Only show tip footer on root help page, not subcommands - Make cre init respect -R/--project-root flag for output location * update creinit * fix tests * gendoc * comment fix
1 parent 124f78b commit d54d1a4

7 files changed

Lines changed: 356 additions & 16 deletions

File tree

cmd/creinit/creinit.go

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/rs/zerolog"
1212
"github.com/spf13/cobra"
1313
"github.com/spf13/viper"
14+
"golang.org/x/term"
1415

1516
"github.com/smartcontractkit/cre-cli/internal/constants"
1617
"github.com/smartcontractkit/cre-cli/internal/runtime"
@@ -22,10 +23,12 @@ import (
2223
)
2324

2425
type Inputs struct {
25-
ProjectName string `validate:"omitempty,project_name" cli:"project-name"`
26-
TemplateName string `validate:"omitempty" cli:"template"`
27-
WorkflowName string `validate:"omitempty,workflow_name" cli:"workflow-name"`
28-
RpcURLs map[string]string // chain-name -> url, from --rpc-url flags
26+
ProjectName string `validate:"omitempty,project_name" cli:"project-name"`
27+
TemplateName string `validate:"omitempty" cli:"template"`
28+
WorkflowName string `validate:"omitempty,workflow_name" cli:"workflow-name"`
29+
RpcURLs map[string]string // chain-name -> url, from --rpc-url flags
30+
NonInteractive bool
31+
ProjectRoot string // from -R / --project-root flag
2932
}
3033

3134
func New(runtimeContext *runtime.Context) *cobra.Command {
@@ -47,6 +50,11 @@ Templates are fetched dynamically from GitHub repositories.`,
4750
if err != nil {
4851
return err
4952
}
53+
54+
// Only use -R if the user explicitly passed it on the command line
55+
if cmd.Flags().Changed(settings.Flags.ProjectRoot.Name) {
56+
inputs.ProjectRoot = runtimeContext.Viper.GetString(settings.Flags.ProjectRoot.Name)
57+
}
5058
if err = h.ValidateInputs(inputs); err != nil {
5159
return err
5260
}
@@ -67,6 +75,7 @@ Templates are fetched dynamically from GitHub repositories.`,
6775
initCmd.Flags().StringP("template", "t", "", "Name of the template to use (e.g., kv-store-go)")
6876
initCmd.Flags().Bool("refresh", false, "Bypass template cache and fetch fresh data")
6977
initCmd.Flags().StringArray("rpc-url", nil, "RPC URL for a network (format: chain-name=url, repeatable)")
78+
initCmd.Flags().Bool("non-interactive", false, "Fail instead of prompting; requires all inputs via flags")
7079

7180
// Deprecated: --template-id is kept for backwards compatibility, maps to hello-world-go
7281
initCmd.Flags().Uint32("template-id", 0, "")
@@ -133,10 +142,11 @@ func (h *handler) ResolveInputs(v *viper.Viper) (Inputs, error) {
133142
}
134143

135144
return Inputs{
136-
ProjectName: v.GetString("project-name"),
137-
TemplateName: templateName,
138-
WorkflowName: v.GetString("workflow-name"),
139-
RpcURLs: rpcURLs,
145+
ProjectName: v.GetString("project-name"),
146+
TemplateName: templateName,
147+
WorkflowName: v.GetString("workflow-name"),
148+
RpcURLs: rpcURLs,
149+
NonInteractive: v.GetBool("non-interactive"),
140150
}, nil
141151
}
142152

@@ -170,6 +180,21 @@ func (h *handler) Execute(inputs Inputs) error {
170180
}
171181
startDir := cwd
172182

183+
// Respect -R / --project-root flag if provided.
184+
// For init, treat -R as the base directory for project creation.
185+
// The directory does not need to exist yet — it will be created during scaffolding.
186+
if inputs.ProjectRoot != "" {
187+
absRoot, err := filepath.Abs(inputs.ProjectRoot)
188+
if err != nil {
189+
return fmt.Errorf("invalid --project-root path: %w", err)
190+
}
191+
// If -R points to a file, that's a user error — it must be a directory
192+
if info, err := os.Stat(absRoot); err == nil && !info.IsDir() {
193+
return fmt.Errorf("--project-root %q is a file, not a directory", inputs.ProjectRoot)
194+
}
195+
startDir = absRoot
196+
}
197+
173198
// Detect if we're in an existing project
174199
existingProjectRoot, _, existingErr := h.findExistingProject(startDir)
175200
isNewProject := existingErr != nil
@@ -218,9 +243,56 @@ func (h *handler) Execute(inputs Inputs) error {
218243
}
219244
}
220245

246+
// Non-interactive mode: validate all required inputs are present
247+
if inputs.NonInteractive {
248+
var missingFlags []string
249+
if isNewProject && inputs.ProjectName == "" {
250+
missingFlags = append(missingFlags, "--project-name")
251+
}
252+
if inputs.TemplateName == "" {
253+
missingFlags = append(missingFlags, "--template")
254+
}
255+
if selectedTemplate != nil {
256+
missing := MissingNetworks(selectedTemplate, inputs.RpcURLs)
257+
for _, network := range missing {
258+
missingFlags = append(missingFlags, fmt.Sprintf("--rpc-url=\"%s=<url>\"", network))
259+
}
260+
if inputs.WorkflowName == "" && selectedTemplate.ProjectDir == "" && len(selectedTemplate.Workflows) <= 1 {
261+
missingFlags = append(missingFlags, "--workflow-name")
262+
}
263+
}
264+
if len(missingFlags) > 0 {
265+
ui.ErrorWithSuggestions(
266+
"Non-interactive mode requires all inputs via flags",
267+
missingFlags,
268+
)
269+
return fmt.Errorf("missing required flags for --non-interactive mode")
270+
}
271+
}
272+
221273
// Run the interactive wizard
222274
result, err := RunWizard(inputs, isNewProject, startDir, workflowTemplates, selectedTemplate)
223275
if err != nil {
276+
// If stdin is not a terminal, the wizard will fail trying to open a TTY.
277+
// Detect this via term.IsTerminal rather than matching third-party error strings.
278+
if !term.IsTerminal(int(os.Stdin.Fd())) { // #nosec G115 -- stdin fd is always 0
279+
var suggestions []string
280+
if selectedTemplate != nil {
281+
missing := MissingNetworks(selectedTemplate, inputs.RpcURLs)
282+
for _, network := range missing {
283+
suggestions = append(suggestions, fmt.Sprintf("--rpc-url=\"%s=<url>\"", network))
284+
}
285+
}
286+
if len(suggestions) > 0 {
287+
ui.ErrorWithSuggestions(
288+
"Interactive mode requires a terminal (TTY). Provide the missing flags to run non-interactively",
289+
suggestions,
290+
)
291+
} else {
292+
ui.Error("Interactive mode requires a terminal (TTY). Use --non-interactive with all required flags, or run in a terminal")
293+
}
294+
return fmt.Errorf("interactive mode requires a terminal (TTY)")
295+
}
224296
return fmt.Errorf("wizard error: %w", err)
225297
}
226298
if result.Cancelled {

cmd/creinit/creinit_test.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,3 +830,186 @@ func TestBuiltInTemplateBackwardsCompat(t *testing.T) {
830830
"built-in template should use user's workflow name")
831831
require.FileExists(t, filepath.Join(projectRoot, "hello-wf", constants.DefaultWorkflowSettingsFileName))
832832
}
833+
834+
func TestMissingNetworks(t *testing.T) {
835+
cases := []struct {
836+
name string
837+
template *templaterepo.TemplateSummary
838+
flags map[string]string
839+
expected []string
840+
}{
841+
{
842+
name: "nil template",
843+
template: nil,
844+
flags: nil,
845+
expected: nil,
846+
},
847+
{
848+
name: "no networks required",
849+
template: &templaterepo.TemplateSummary{
850+
TemplateMetadata: templaterepo.TemplateMetadata{},
851+
},
852+
flags: nil,
853+
expected: nil,
854+
},
855+
{
856+
name: "all provided",
857+
template: &testMultiNetworkTemplate,
858+
flags: map[string]string{
859+
"ethereum-testnet-sepolia": "https://rpc1.example.com",
860+
"ethereum-mainnet": "https://rpc2.example.com",
861+
},
862+
expected: nil,
863+
},
864+
{
865+
name: "some missing",
866+
template: &testMultiNetworkTemplate,
867+
flags: map[string]string{
868+
"ethereum-testnet-sepolia": "https://rpc1.example.com",
869+
},
870+
expected: []string{"ethereum-mainnet"},
871+
},
872+
{
873+
name: "all missing",
874+
template: &testMultiNetworkTemplate,
875+
flags: map[string]string{},
876+
expected: []string{"ethereum-testnet-sepolia", "ethereum-mainnet"},
877+
},
878+
}
879+
880+
for _, tc := range cases {
881+
t.Run(tc.name, func(t *testing.T) {
882+
result := MissingNetworks(tc.template, tc.flags)
883+
require.Equal(t, tc.expected, result)
884+
})
885+
}
886+
}
887+
888+
func TestNonInteractiveMissingFlags(t *testing.T) {
889+
sim := chainsim.NewSimulatedEnvironment(t)
890+
defer sim.Close()
891+
892+
tempDir := t.TempDir()
893+
restoreCwd, err := testutil.ChangeWorkingDirectory(tempDir)
894+
require.NoError(t, err)
895+
defer restoreCwd()
896+
897+
inputs := Inputs{
898+
ProjectName: "proj",
899+
TemplateName: "test-multichain",
900+
WorkflowName: "",
901+
NonInteractive: true,
902+
RpcURLs: map[string]string{},
903+
}
904+
905+
h := newHandlerWithRegistry(sim.NewRuntimeContext(), newMockRegistry())
906+
require.NoError(t, h.ValidateInputs(inputs))
907+
err = h.Execute(inputs)
908+
require.Error(t, err)
909+
require.Contains(t, err.Error(), "missing required flags for --non-interactive mode")
910+
}
911+
912+
func TestNonInteractiveAllFlagsProvided(t *testing.T) {
913+
sim := chainsim.NewSimulatedEnvironment(t)
914+
defer sim.Close()
915+
916+
tempDir := t.TempDir()
917+
restoreCwd, err := testutil.ChangeWorkingDirectory(tempDir)
918+
require.NoError(t, err)
919+
defer restoreCwd()
920+
921+
inputs := Inputs{
922+
ProjectName: "niProj",
923+
TemplateName: "hello-world-go",
924+
WorkflowName: "my-wf",
925+
NonInteractive: true,
926+
}
927+
928+
h := newHandlerWithRegistry(sim.NewRuntimeContext(), newMockRegistry())
929+
require.NoError(t, h.ValidateInputs(inputs))
930+
require.NoError(t, h.Execute(inputs))
931+
932+
projectRoot := filepath.Join(tempDir, "niProj")
933+
require.DirExists(t, filepath.Join(projectRoot, "my-wf"))
934+
}
935+
936+
func TestInitRespectsProjectRootFlag(t *testing.T) {
937+
sim := chainsim.NewSimulatedEnvironment(t)
938+
defer sim.Close()
939+
940+
// CWD is a temp dir (simulating being "somewhere else")
941+
cwdDir := t.TempDir()
942+
restoreCwd, err := testutil.ChangeWorkingDirectory(cwdDir)
943+
require.NoError(t, err)
944+
defer restoreCwd()
945+
946+
// Target directory is a separate temp dir (simulating -R flag)
947+
targetDir := t.TempDir()
948+
949+
inputs := Inputs{
950+
ProjectName: "myproj",
951+
TemplateName: "test-go",
952+
WorkflowName: "mywf",
953+
RpcURLs: map[string]string{"ethereum-testnet-sepolia": "https://rpc.example.com"},
954+
ProjectRoot: targetDir,
955+
}
956+
957+
ctx := sim.NewRuntimeContext()
958+
959+
h := newHandlerWithRegistry(ctx, newMockRegistry())
960+
require.NoError(t, h.ValidateInputs(inputs))
961+
require.NoError(t, h.Execute(inputs))
962+
963+
// Project should be created under targetDir, NOT cwdDir
964+
projectRoot := filepath.Join(targetDir, "myproj")
965+
validateInitProjectStructure(t, projectRoot, "mywf", GetTemplateFileListGo())
966+
967+
// Verify nothing was created in CWD
968+
entries, err := os.ReadDir(cwdDir)
969+
require.NoError(t, err)
970+
require.Empty(t, entries, "CWD should be untouched when -R is provided")
971+
}
972+
973+
func TestInitProjectRootFlagFindsExistingProject(t *testing.T) {
974+
sim := chainsim.NewSimulatedEnvironment(t)
975+
defer sim.Close()
976+
977+
// CWD is a clean temp dir with no project
978+
cwdDir := t.TempDir()
979+
restoreCwd, err := testutil.ChangeWorkingDirectory(cwdDir)
980+
require.NoError(t, err)
981+
defer restoreCwd()
982+
983+
// Create an "existing project" in a separate directory
984+
existingProject := t.TempDir()
985+
require.NoError(t, os.WriteFile(
986+
filepath.Join(existingProject, constants.DefaultProjectSettingsFileName),
987+
[]byte("name: existing"), 0600,
988+
))
989+
require.NoError(t, os.WriteFile(
990+
filepath.Join(existingProject, constants.DefaultEnvFileName),
991+
[]byte(""), 0600,
992+
))
993+
994+
inputs := Inputs{
995+
ProjectName: "",
996+
TemplateName: "test-go",
997+
WorkflowName: "new-workflow",
998+
RpcURLs: map[string]string{"ethereum-testnet-sepolia": "https://rpc.example.com"},
999+
ProjectRoot: existingProject,
1000+
}
1001+
1002+
ctx := sim.NewRuntimeContext()
1003+
1004+
h := newHandlerWithRegistry(ctx, newMockRegistry())
1005+
require.NoError(t, h.ValidateInputs(inputs))
1006+
require.NoError(t, h.Execute(inputs))
1007+
1008+
// Workflow should be scaffolded into the existing project
1009+
validateInitProjectStructure(
1010+
t,
1011+
existingProject,
1012+
"new-workflow",
1013+
GetTemplateFileListGo(),
1014+
)
1015+
}

cmd/creinit/wizard.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,22 @@ func RunWizard(inputs Inputs, isNewProject bool, startDir string, templates []te
992992
return result, nil
993993
}
994994

995+
// MissingNetworks returns the network names from the template that were not
996+
// provided via --rpc-url flags. Returns nil if all networks are covered or
997+
// the template has no network requirements.
998+
func MissingNetworks(template *templaterepo.TemplateSummary, flagRpcURLs map[string]string) []string {
999+
if template == nil || len(template.Networks) == 0 {
1000+
return nil
1001+
}
1002+
var missing []string
1003+
for _, network := range template.Networks {
1004+
if _, ok := flagRpcURLs[network]; !ok {
1005+
missing = append(missing, network)
1006+
}
1007+
}
1008+
return missing
1009+
}
1010+
9951011
// validateRpcURL validates that a URL is a valid HTTP/HTTPS URL.
9961012
func validateRpcURL(rawURL string) error {
9971013
u, err := url.Parse(rawURL)

cmd/template/help_template.tpl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@
8686
{{styleDim (printf "Use \"%s [command] --help\" for more information about a command." .CommandPath)}}
8787
{{- end }}
8888

89+
{{- if not .HasParent}}
90+
8991
{{styleSuccess "Tip:"}} New here? Run:
9092
{{styleCode "$ cre login"}}
9193
to login into your cre account, then:
@@ -94,9 +96,10 @@
9496
{{- if needsDeployAccess}}
9597

9698
🔑 Ready to deploy? Run:
97-
$ cre account access
99+
{{styleCode "$ cre account access"}}
98100
to request deployment access.
99101
{{- end}}
102+
{{- end}}
100103

101104
{{styleSection "Need more help?"}}
102105
Visit {{styleURL "https://docs.chain.link/cre"}}

0 commit comments

Comments
 (0)