diff --git a/commands/run.go b/commands/run.go index 34485536..a7b09e85 100644 --- a/commands/run.go +++ b/commands/run.go @@ -1,6 +1,7 @@ package commands import ( + "encoding/json" "errors" "kool-dev/kool/core/builder" "kool-dev/kool/core/environment" @@ -15,6 +16,7 @@ import ( // KoolRunFlags holds the flags for the run command type KoolRunFlags struct { EnvVariables []string + JSON bool } // KoolRun holds handlers and functions to implement the run command logic @@ -48,7 +50,7 @@ func AddKoolRun(root *cobra.Command) { func NewKoolRun() *KoolRun { return &KoolRun{ *newDefaultKoolService(), - &KoolRunFlags{[]string{}}, + &KoolRunFlags{[]string{}, false}, parser.NewParser(), environment.NewEnvStorage(), shell.NewPromptSelect(), @@ -58,7 +60,15 @@ func NewKoolRun() *KoolRun { // Execute runs the run logic with incoming arguments. func (r *KoolRun) Execute(originalArgs []string) (err error) { + // look for kool.yml on current working directory + _ = r.parser.AddLookupPath(r.env.Get("PWD")) + // look for kool.yml on kool folder within user home directory + _ = r.parser.AddLookupPath(path.Join(r.env.Get("HOME"), "kool")) + if len(originalArgs) == 0 { + if r.Flags.JSON { + return r.printScriptsJSON("") + } r.shell.Info("\nAvailable scripts:\n") scripts := compListScripts("", r) for _, cmd := range scripts { @@ -74,11 +84,6 @@ func (r *KoolRun) Execute(originalArgs []string) (err error) { args []string = originalArgs[1:] ) - // look for kool.yml on current working directory - _ = r.parser.AddLookupPath(r.env.Get("PWD")) - // look for kool.yml on kool folder within user home directory - _ = r.parser.AddLookupPath(path.Join(r.env.Get("HOME"), "kool")) - if err = r.parseScript(script); err != nil { return } @@ -125,6 +130,7 @@ A single-line SCRIPT can be run with optional arguments.`, } runCmd.Flags().StringArrayVarP(&run.Flags.EnvVariables, "env", "e", []string{}, "Environment variables.") + runCmd.Flags().BoolVar(&run.Flags.JSON, "json", false, "Output available scripts as JSON (use without script argument)") // after a non-flag arg, stop parsing flags runCmd.Flags().SetInterspersed(false) @@ -246,3 +252,31 @@ func compListScripts(toComplete string, run *KoolRun) (scripts []string) { return } + +func (r *KoolRun) printScriptsJSON(filter string) (err error) { + var details []parser.ScriptDetail + if details, err = r.parser.ParseAvailableScriptsDetails(filter); err != nil { + return + } + + if details == nil { + details = []parser.ScriptDetail{} + } + + for i := range details { + if details[i].Comments == nil { + details[i].Comments = []string{} + } + if details[i].Commands == nil { + details[i].Commands = []string{} + } + } + + var payload []byte + if payload, err = json.Marshal(details); err != nil { + return + } + + r.Shell().Println(string(payload)) + return nil +} diff --git a/commands/run_test.go b/commands/run_test.go index 480b94ca..ff78cd89 100644 --- a/commands/run_test.go +++ b/commands/run_test.go @@ -1,6 +1,7 @@ package commands import ( + "encoding/json" "errors" "fmt" "io" @@ -18,7 +19,7 @@ import ( func newFakeKoolRun(mockParsedCommands map[string][]builder.Command, mockParseError map[string]error) *KoolRun { return &KoolRun{ *(newDefaultKoolService().Fake()), - &KoolRunFlags{[]string{}}, + &KoolRunFlags{[]string{}, false}, &parser.FakeParser{MockParsedCommands: mockParsedCommands, MockParseError: mockParseError}, environment.NewFakeEnvStorage(), &shell.FakePromptSelect{}, @@ -745,3 +746,107 @@ func TestNewRunCommandWithTypoErrorCancelled(t *testing.T) { t.Errorf("expecting warning '%s', got '%s'", expected, output) } } + +func TestNewRunCommandJsonOutput(t *testing.T) { + f := newFakeKoolRun(nil, nil) + f.parser.(*parser.FakeParser).MockScriptDetails = []parser.ScriptDetail{ + { + Name: "setup", + Comments: []string{"Sets up dependencies"}, + Commands: []string{"kool run composer install"}, + }, + { + Name: "lint", + Comments: []string{}, + Commands: []string{"kool run go:linux fmt ./..."}, + }, + } + + cmd := NewRunCommand(f) + cmd.SetArgs([]string{"--json"}) + + if err := cmd.Execute(); err != nil { + t.Errorf("unexpected error executing run command with --json; error: %v", err) + } + + if !f.parser.(*parser.FakeParser).CalledParseAvailableDetails { + t.Error("did not call ParseAvailableScriptsDetails") + } + + fakeShell := f.shell.(*shell.FakeShell) + + if len(fakeShell.OutLines) == 0 { + t.Error("expected JSON output") + return + } + + var output []parser.ScriptDetail + if err := json.Unmarshal([]byte(fakeShell.OutLines[0]), &output); err != nil { + t.Fatalf("failed to parse json output: %v", err) + } + + if len(output) != 2 { + t.Fatalf("expected 2 script entries, got %d", len(output)) + } + + if output[0].Name != "lint" || output[1].Name != "setup" { + t.Errorf("unexpected scripts order or names: %v", output) + } +} + +func TestNewRunCommandJsonOutputEmpty(t *testing.T) { + f := newFakeKoolRun(nil, nil) + f.parser.(*parser.FakeParser).MockScriptDetails = []parser.ScriptDetail{} + + cmd := NewRunCommand(f) + cmd.SetArgs([]string{"--json"}) + + if err := cmd.Execute(); err != nil { + t.Errorf("unexpected error executing run command with --json; error: %v", err) + } + + fakeShell := f.shell.(*shell.FakeShell) + + if len(fakeShell.OutLines) == 0 { + t.Error("expected JSON output") + return + } + + // Should output empty array, not null + if fakeShell.OutLines[0] != "[]" { + t.Errorf("expected empty JSON array '[]', got '%s'", fakeShell.OutLines[0]) + } +} + +func TestNewRunCommandJsonOutputNullSafety(t *testing.T) { + f := newFakeKoolRun(nil, nil) + f.parser.(*parser.FakeParser).MockScriptDetails = []parser.ScriptDetail{ + { + Name: "test", + Comments: nil, // nil comments + Commands: nil, // nil commands + }, + } + + cmd := NewRunCommand(f) + cmd.SetArgs([]string{"--json"}) + + if err := cmd.Execute(); err != nil { + t.Errorf("unexpected error executing run command with --json; error: %v", err) + } + + fakeShell := f.shell.(*shell.FakeShell) + + var output []parser.ScriptDetail + if err := json.Unmarshal([]byte(fakeShell.OutLines[0]), &output); err != nil { + t.Fatalf("failed to parse json output: %v", err) + } + + // Verify nil values are converted to empty arrays + if output[0].Comments == nil { + t.Error("Comments should not be nil in JSON output") + } + if output[0].Commands == nil { + t.Error("Commands should not be nil in JSON output") + } +} diff --git a/core/parser/fake_parser.go b/core/parser/fake_parser.go index a6f8c9c1..ae10bd4d 100644 --- a/core/parser/fake_parser.go +++ b/core/parser/fake_parser.go @@ -2,6 +2,7 @@ package parser import ( "kool-dev/kool/core/builder" + "sort" "strings" ) @@ -11,10 +12,13 @@ type FakeParser struct { TargetFiles []string CalledParse bool CalledParseAvailableScripts bool + CalledParseAvailableDetails bool MockParsedCommands map[string][]builder.Command MockParseError map[string]error MockScripts []string + MockScriptDetails []ScriptDetail MockParseAvailableScriptsError error + MockParseAvailableDetailsError error } // AddLookupPath implements fake AddLookupPath behavior @@ -49,3 +53,27 @@ func (f *FakeParser) ParseAvailableScripts(filter string) (scripts []string, err err = f.MockParseAvailableScriptsError return } + +// ParseAvailableScriptsDetails implements fake ParseAvailableScriptsDetails behavior +func (f *FakeParser) ParseAvailableScriptsDetails(filter string) (details []ScriptDetail, err error) { + f.CalledParseAvailableDetails = true + + if filter == "" { + details = append(details, f.MockScriptDetails...) + } else { + for _, detail := range f.MockScriptDetails { + if strings.HasPrefix(detail.Name, filter) { + details = append(details, detail) + } + } + } + + if len(details) > 1 { + sort.Slice(details, func(i, j int) bool { + return details[i].Name < details[j].Name + }) + } + + err = f.MockParseAvailableDetailsError + return +} diff --git a/core/parser/parser.go b/core/parser/parser.go index 6ef49ef3..c35662f7 100644 --- a/core/parser/parser.go +++ b/core/parser/parser.go @@ -15,6 +15,7 @@ type Parser interface { AddLookupPath(string) error Parse(string) ([]builder.Command, error) ParseAvailableScripts(string) ([]string, error) + ParseAvailableScriptsDetails(string) ([]ScriptDetail, error) } // DefaultParser implements all default behavior for using kool.yml files. @@ -142,3 +143,50 @@ func (p *DefaultParser) ParseAvailableScripts(filter string) (scripts []string, return } + +// ParseAvailableScriptsDetails parses all available scripts with details +func (p *DefaultParser) ParseAvailableScriptsDetails(filter string) (details []ScriptDetail, err error) { + var ( + koolFile string + parsedFile *KoolYaml + found map[string]ScriptDetail + keys []string + ) + + if len(p.targetFiles) == 0 { + err = errors.New("kool.yml not found") + return + } + + found = make(map[string]ScriptDetail) + + for _, koolFile = range p.targetFiles { + if parsedFile, err = ParseKoolYamlWithDetails(koolFile); err != nil { + return + } + + for name, detail := range parsedFile.ScriptDetails { + if _, exists := found[name]; exists { + continue + } + if filter != "" && !strings.HasPrefix(name, filter) { + continue + } + found[name] = detail + } + } + + for name := range found { + keys = append(keys, name) + } + if len(keys) == 0 { + return + } + + sort.Strings(keys) + for _, name := range keys { + details = append(details, found[name]) + } + + return +} diff --git a/core/parser/parser_test.go b/core/parser/parser_test.go index e1aad525..742a7800 100644 --- a/core/parser/parser_test.go +++ b/core/parser/parser_test.go @@ -150,3 +150,34 @@ func TestParserParseAvailableScriptsFilter(t *testing.T) { t.Error("failed to get filtered scripts from kool.yml") } } + +func TestParserParseAvailableScriptsDetails(t *testing.T) { + var ( + p Parser = NewParser() + details []ScriptDetail + err error + ) + + if _, err = p.ParseAvailableScriptsDetails(""); err == nil { + t.Error("expecting 'kool.yml not found' error, got none") + } + + if err != nil && err.Error() != "kool.yml not found" { + t.Errorf("expecting error 'kool.yml not found', got '%s'", err.Error()) + } + + workDir, _ := os.Getwd() + _ = p.AddLookupPath(path.Join(workDir, "testing_files")) + + if details, err = p.ParseAvailableScriptsDetails(""); err != nil { + t.Errorf("unexpected error; error: %s", err) + } + + if len(details) != 1 || details[0].Name != "testing" { + t.Error("failed to get script details from kool.yml") + } + + if len(details[0].Commands) != 1 || details[0].Commands[0] != "echo testing" { + t.Errorf("unexpected command details: %v", details[0].Commands) + } +} diff --git a/core/parser/yml.go b/core/parser/yml.go index bd27f575..9bbff76d 100644 --- a/core/parser/yml.go +++ b/core/parser/yml.go @@ -5,9 +5,10 @@ import ( "io" "kool-dev/kool/core/builder" "os" + "strings" "github.com/agnivade/levenshtein" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) // SimilarThreshold represents the minimal Levenshteindistance of two @@ -18,7 +19,15 @@ type yamlMarshalFnType func(interface{}) ([]byte, error) // KoolYaml holds the structure for parsing the custom commands file type KoolYaml struct { - Scripts map[string]interface{} `yaml:"scripts"` + Scripts map[string]interface{} `yaml:"scripts"` + ScriptDetails map[string]ScriptDetail `yaml:"-"` +} + +// ScriptDetail describes a kool.yml script with context +type ScriptDetail struct { + Name string `json:"name"` + Comments []string `json:"comments"` + Commands []string `json:"commands"` } // KoolYamlParser holds logic for handling kool yaml @@ -51,19 +60,53 @@ func ParseKoolYaml(filePath string) (parsed *KoolYaml, err error) { } parsed = new(KoolYaml) - err = yaml.Unmarshal(raw, parsed) + if err = yaml.Unmarshal(raw, parsed); err != nil { + return + } return } +// ParseKoolYamlWithDetails decodes the target kool.yml and includes script details. +func ParseKoolYamlWithDetails(filePath string) (parsed *KoolYaml, err error) { + var ( + file *os.File + raw []byte + root yaml.Node + ) + + if file, err = os.OpenFile(filePath, os.O_RDONLY, os.ModePerm); err != nil { + return + } + + defer file.Close() + + if raw, err = io.ReadAll(file); err != nil { + return + } + + parsed = new(KoolYaml) + if err = yaml.Unmarshal(raw, parsed); err != nil { + return + } + + if err = yaml.Unmarshal(raw, &root); err != nil { + return + } + + parsed.ScriptDetails = parseScriptDetails(&root) + return +} + // Parse decodes the target kool.yml func (y *KoolYaml) Parse(filePath string) (err error) { var parsed *KoolYaml - if parsed, err = ParseKoolYaml(filePath); err != nil { + if parsed, err = ParseKoolYamlWithDetails(filePath); err != nil { return } y.Scripts = parsed.Scripts + y.ScriptDetails = parsed.ScriptDetails return } @@ -95,6 +138,7 @@ func (y *KoolYaml) ParseCommands(script string) (commands []builder.Command, err isList bool line string lines []interface{} + linesStr []string command *builder.DefaultCommand ) @@ -104,9 +148,22 @@ func (y *KoolYaml) ParseCommands(script string) (commands []builder.Command, err } commands = append(commands, command) + } else if linesStr, isList = y.Scripts[script].([]string); isList { + for _, line := range linesStr { + if command, err = builder.ParseCommand(line); err != nil { + return + } + + commands = append(commands, command) + } } else if lines, isList = y.Scripts[script].([]interface{}); isList { for _, i := range lines { - if command, err = builder.ParseCommand(i.(string)); err != nil { + var lineStr string + if lineStr, isSingle = i.(string); !isSingle { + err = fmt.Errorf("failed parsing script '%s': expected string or array of strings", script) + return + } + if command, err = builder.ParseCommand(lineStr); err != nil { return } @@ -128,6 +185,18 @@ func (y *KoolYaml) SetScript(key string, commands []string) { y.Scripts = make(map[string]interface{}) } + if y.ScriptDetails == nil { + y.ScriptDetails = make(map[string]ScriptDetail) + } + + currentDetail := y.ScriptDetails[key] + currentDetail.Name = key + currentDetail.Commands = append([]string{}, commands...) + if currentDetail.Comments == nil { + currentDetail.Comments = []string{} + } + y.ScriptDetails[key] = currentDetail + if len(commands) == 1 { y.Scripts[key] = commands[0] } else { @@ -151,3 +220,120 @@ func (y *KoolYaml) String() (content string, err error) { content = string(parsedBytes) return } + +func parseScriptDetails(root *yaml.Node) map[string]ScriptDetail { + result := make(map[string]ScriptDetail) + if root == nil { + return result + } + + scriptsNode := findScriptsNode(root) + if scriptsNode == nil || scriptsNode.Kind != yaml.MappingNode { + return result + } + + for i := 0; i+1 < len(scriptsNode.Content); i += 2 { + keyNode := scriptsNode.Content[i] + valueNode := scriptsNode.Content[i+1] + name := keyNode.Value + detail := ScriptDetail{ + Name: name, + Comments: collectComments(keyNode, valueNode), + Commands: parseCommandsNode(valueNode), + } + if detail.Comments == nil { + detail.Comments = []string{} + } + if detail.Commands == nil { + detail.Commands = []string{} + } + result[name] = detail + } + + return result +} + +func findScriptsNode(root *yaml.Node) *yaml.Node { + current := root + if current.Kind == yaml.DocumentNode && len(current.Content) > 0 { + current = current.Content[0] + } + + if current.Kind != yaml.MappingNode { + return nil + } + + for i := 0; i+1 < len(current.Content); i += 2 { + keyNode := current.Content[i] + valueNode := current.Content[i+1] + if keyNode.Value == "scripts" { + return valueNode + } + } + + return nil +} + +func parseCommandsNode(node *yaml.Node) []string { + if node == nil { + return []string{} + } + + switch node.Kind { + case yaml.ScalarNode: + return []string{node.Value} + case yaml.SequenceNode: + commands := make([]string, 0, len(node.Content)) + for _, item := range node.Content { + if item.Kind == yaml.ScalarNode { + commands = append(commands, item.Value) + } + } + return commands + default: + return []string{} + } +} + +func collectComments(nodes ...*yaml.Node) []string { + var comments []string + for _, node := range nodes { + if node == nil { + continue + } + comments = appendCommentLines(comments, node.HeadComment) + comments = appendCommentLines(comments, node.LineComment) + } + + return comments +} + +func appendCommentLines(comments []string, raw string) []string { + if raw == "" { + return comments + } + + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + line = strings.TrimPrefix(line, "#") + line = strings.TrimSpace(line) + if line == "" { + continue + } + if !containsString(comments, line) { + comments = append(comments, line) + } + } + + return comments +} + +func containsString(items []string, value string) bool { + for _, item := range items { + if item == value { + return true + } + } + + return false +} diff --git a/core/parser/yml_test.go b/core/parser/yml_test.go index a436c0ee..c1cb149e 100644 --- a/core/parser/yml_test.go +++ b/core/parser/yml_test.go @@ -5,8 +5,9 @@ import ( "kool-dev/kool/core/builder" "os" "path" - "strings" "testing" + + "gopkg.in/yaml.v3" ) const KoolYmlOK = `scripts: @@ -123,17 +124,86 @@ func TestParseKoolYaml(t *testing.T) { return } - expected := `scripts: - multi-line: - - line 1 - - line 2 - new-script: - - new-command 1 - - new-command 2 - single-line: single line script` - - if expected != strings.TrimSpace(koolContent) { - t.Errorf("expecting kool.yml content '%s', got '%s'", expected, strings.TrimSpace(koolContent)) + parsedOutput := new(KoolYaml) + if err = yaml.Unmarshal([]byte(koolContent), parsedOutput); err != nil { + t.Errorf("failed to parse generated kool.yml content; error: %s", err) + return + } + + if len(parsedOutput.Scripts) != 3 { + t.Errorf("expected to parse 3 scripts from generated content; got %d", len(parsedOutput.Scripts)) + return + } + + if !parsedOutput.HasScript("single-line") || !parsedOutput.HasScript("multi-line") || !parsedOutput.HasScript("new-script") { + t.Errorf("expected to have single-line, multi-line and new-script scripts") + return + } + + if cmds, err = parsedOutput.ParseCommands("new-script"); err != nil { + t.Errorf("failed to parse new-script from generated content; error: %s", err) + return + } + + if len(cmds) != 2 { + t.Errorf("expected new-script to parse 2 commands; got %d", len(cmds)) + return + } +} + +func TestParseKoolYamlScriptDetails(t *testing.T) { + const KoolYmlWithComments = `scripts: + # build the app + # and setup + setup: kool run go build + lint: + - kool run go vet + - kool run go fmt +` + + var ( + err error + parsed *KoolYaml + tmp string + ) + + tmp = path.Join(t.TempDir(), "kool.yml") + if err = os.WriteFile(tmp, []byte(KoolYmlWithComments), os.ModePerm); err != nil { + t.Fatal("failed creating temporary file for test", err) + } + + if parsed, err = ParseKoolYamlWithDetails(tmp); err != nil { + t.Fatalf("failed parsing kool.yml with comments; error: %s", err) + } + + setup, ok := parsed.ScriptDetails["setup"] + if !ok { + t.Fatal("expected to find setup script details") + } + + if len(setup.Comments) != 2 { + t.Fatalf("expected 2 comments for setup, got %v", setup.Comments) + } + + if setup.Comments[0] != "build the app" || setup.Comments[1] != "and setup" { + t.Fatalf("unexpected setup comments: %v", setup.Comments) + } + + if setup.Commands == nil || len(setup.Commands) != 1 || setup.Commands[0] != "kool run go build" { + t.Fatalf("unexpected setup commands: %v", setup.Commands) + } + + lint, ok := parsed.ScriptDetails["lint"] + if !ok { + t.Fatal("expected to find lint script details") + } + + if len(lint.Comments) != 0 { + t.Fatalf("expected no comments for lint, got %v", lint.Comments) + } + + if len(lint.Commands) != 2 { + t.Fatalf("expected 2 commands for lint, got %v", lint.Commands) } } diff --git a/docs/05-Commands-Reference/kool-run.md b/docs/05-Commands-Reference/kool-run.md index 4784372a..fce530ac 100644 --- a/docs/05-Commands-Reference/kool-run.md +++ b/docs/05-Commands-Reference/kool-run.md @@ -16,6 +16,7 @@ kool run SCRIPT [--] [ARG...] ``` -e, --env stringArray Environment variables. -h, --help help for run + --json Output available scripts as JSON (use without script argument) ``` ### Options inherited from parent commands diff --git a/skills/kool-cli/SKILL.md b/skills/kool-cli/SKILL.md new file mode 100644 index 00000000..e919f07f --- /dev/null +++ b/skills/kool-cli/SKILL.md @@ -0,0 +1,106 @@ +--- +name: kool-cli +description: Docker development environment CLI. Use for managing containers (start/stop/restart), executing commands in services, viewing logs, and running project scripts from kool.yml. +--- + +# Kool CLI + +Kool simplifies Docker-based development with commands for container lifecycle, service execution, and custom scripts. + +## Quick Reference + +```bash +kool start # Start all services from docker-compose.yml +kool stop # Stop all services +kool restart --rebuild # Restart and rebuild images +kool status # Show running containers +kool exec # Run command in service container +kool logs -f # Follow service logs +kool run --json # List available scripts as JSON +kool run