Skip to content

Commit a811231

Browse files
authored
Merge pull request #531 from kool-dev/skills
Adding Support for skills.sh
2 parents ba62333 + 48dc6bf commit a811231

File tree

9 files changed

+633
-24
lines changed

9 files changed

+633
-24
lines changed

commands/run.go

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package commands
22

33
import (
4+
"encoding/json"
45
"errors"
56
"kool-dev/kool/core/builder"
67
"kool-dev/kool/core/environment"
@@ -15,6 +16,7 @@ import (
1516
// KoolRunFlags holds the flags for the run command
1617
type KoolRunFlags struct {
1718
EnvVariables []string
19+
JSON bool
1820
}
1921

2022
// KoolRun holds handlers and functions to implement the run command logic
@@ -48,7 +50,7 @@ func AddKoolRun(root *cobra.Command) {
4850
func NewKoolRun() *KoolRun {
4951
return &KoolRun{
5052
*newDefaultKoolService(),
51-
&KoolRunFlags{[]string{}},
53+
&KoolRunFlags{[]string{}, false},
5254
parser.NewParser(),
5355
environment.NewEnvStorage(),
5456
shell.NewPromptSelect(),
@@ -58,7 +60,15 @@ func NewKoolRun() *KoolRun {
5860

5961
// Execute runs the run logic with incoming arguments.
6062
func (r *KoolRun) Execute(originalArgs []string) (err error) {
63+
// look for kool.yml on current working directory
64+
_ = r.parser.AddLookupPath(r.env.Get("PWD"))
65+
// look for kool.yml on kool folder within user home directory
66+
_ = r.parser.AddLookupPath(path.Join(r.env.Get("HOME"), "kool"))
67+
6168
if len(originalArgs) == 0 {
69+
if r.Flags.JSON {
70+
return r.printScriptsJSON("")
71+
}
6272
r.shell.Info("\nAvailable scripts:\n")
6373
scripts := compListScripts("", r)
6474
for _, cmd := range scripts {
@@ -74,11 +84,6 @@ func (r *KoolRun) Execute(originalArgs []string) (err error) {
7484
args []string = originalArgs[1:]
7585
)
7686

77-
// look for kool.yml on current working directory
78-
_ = r.parser.AddLookupPath(r.env.Get("PWD"))
79-
// look for kool.yml on kool folder within user home directory
80-
_ = r.parser.AddLookupPath(path.Join(r.env.Get("HOME"), "kool"))
81-
8287
if err = r.parseScript(script); err != nil {
8388
return
8489
}
@@ -125,6 +130,7 @@ A single-line SCRIPT can be run with optional arguments.`,
125130
}
126131

127132
runCmd.Flags().StringArrayVarP(&run.Flags.EnvVariables, "env", "e", []string{}, "Environment variables.")
133+
runCmd.Flags().BoolVar(&run.Flags.JSON, "json", false, "Output available scripts as JSON (use without script argument)")
128134

129135
// after a non-flag arg, stop parsing flags
130136
runCmd.Flags().SetInterspersed(false)
@@ -246,3 +252,31 @@ func compListScripts(toComplete string, run *KoolRun) (scripts []string) {
246252

247253
return
248254
}
255+
256+
func (r *KoolRun) printScriptsJSON(filter string) (err error) {
257+
var details []parser.ScriptDetail
258+
if details, err = r.parser.ParseAvailableScriptsDetails(filter); err != nil {
259+
return
260+
}
261+
262+
if details == nil {
263+
details = []parser.ScriptDetail{}
264+
}
265+
266+
for i := range details {
267+
if details[i].Comments == nil {
268+
details[i].Comments = []string{}
269+
}
270+
if details[i].Commands == nil {
271+
details[i].Commands = []string{}
272+
}
273+
}
274+
275+
var payload []byte
276+
if payload, err = json.Marshal(details); err != nil {
277+
return
278+
}
279+
280+
r.Shell().Println(string(payload))
281+
return nil
282+
}

commands/run_test.go

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package commands
22

33
import (
4+
"encoding/json"
45
"errors"
56
"fmt"
67
"io"
@@ -18,7 +19,7 @@ import (
1819
func newFakeKoolRun(mockParsedCommands map[string][]builder.Command, mockParseError map[string]error) *KoolRun {
1920
return &KoolRun{
2021
*(newDefaultKoolService().Fake()),
21-
&KoolRunFlags{[]string{}},
22+
&KoolRunFlags{[]string{}, false},
2223
&parser.FakeParser{MockParsedCommands: mockParsedCommands, MockParseError: mockParseError},
2324
environment.NewFakeEnvStorage(),
2425
&shell.FakePromptSelect{},
@@ -745,3 +746,107 @@ func TestNewRunCommandWithTypoErrorCancelled(t *testing.T) {
745746
t.Errorf("expecting warning '%s', got '%s'", expected, output)
746747
}
747748
}
749+
750+
func TestNewRunCommandJsonOutput(t *testing.T) {
751+
f := newFakeKoolRun(nil, nil)
752+
f.parser.(*parser.FakeParser).MockScriptDetails = []parser.ScriptDetail{
753+
{
754+
Name: "setup",
755+
Comments: []string{"Sets up dependencies"},
756+
Commands: []string{"kool run composer install"},
757+
},
758+
{
759+
Name: "lint",
760+
Comments: []string{},
761+
Commands: []string{"kool run go:linux fmt ./..."},
762+
},
763+
}
764+
765+
cmd := NewRunCommand(f)
766+
cmd.SetArgs([]string{"--json"})
767+
768+
if err := cmd.Execute(); err != nil {
769+
t.Errorf("unexpected error executing run command with --json; error: %v", err)
770+
}
771+
772+
if !f.parser.(*parser.FakeParser).CalledParseAvailableDetails {
773+
t.Error("did not call ParseAvailableScriptsDetails")
774+
}
775+
776+
fakeShell := f.shell.(*shell.FakeShell)
777+
778+
if len(fakeShell.OutLines) == 0 {
779+
t.Error("expected JSON output")
780+
return
781+
}
782+
783+
var output []parser.ScriptDetail
784+
if err := json.Unmarshal([]byte(fakeShell.OutLines[0]), &output); err != nil {
785+
t.Fatalf("failed to parse json output: %v", err)
786+
}
787+
788+
if len(output) != 2 {
789+
t.Fatalf("expected 2 script entries, got %d", len(output))
790+
}
791+
792+
if output[0].Name != "lint" || output[1].Name != "setup" {
793+
t.Errorf("unexpected scripts order or names: %v", output)
794+
}
795+
}
796+
797+
func TestNewRunCommandJsonOutputEmpty(t *testing.T) {
798+
f := newFakeKoolRun(nil, nil)
799+
f.parser.(*parser.FakeParser).MockScriptDetails = []parser.ScriptDetail{}
800+
801+
cmd := NewRunCommand(f)
802+
cmd.SetArgs([]string{"--json"})
803+
804+
if err := cmd.Execute(); err != nil {
805+
t.Errorf("unexpected error executing run command with --json; error: %v", err)
806+
}
807+
808+
fakeShell := f.shell.(*shell.FakeShell)
809+
810+
if len(fakeShell.OutLines) == 0 {
811+
t.Error("expected JSON output")
812+
return
813+
}
814+
815+
// Should output empty array, not null
816+
if fakeShell.OutLines[0] != "[]" {
817+
t.Errorf("expected empty JSON array '[]', got '%s'", fakeShell.OutLines[0])
818+
}
819+
}
820+
821+
func TestNewRunCommandJsonOutputNullSafety(t *testing.T) {
822+
f := newFakeKoolRun(nil, nil)
823+
f.parser.(*parser.FakeParser).MockScriptDetails = []parser.ScriptDetail{
824+
{
825+
Name: "test",
826+
Comments: nil, // nil comments
827+
Commands: nil, // nil commands
828+
},
829+
}
830+
831+
cmd := NewRunCommand(f)
832+
cmd.SetArgs([]string{"--json"})
833+
834+
if err := cmd.Execute(); err != nil {
835+
t.Errorf("unexpected error executing run command with --json; error: %v", err)
836+
}
837+
838+
fakeShell := f.shell.(*shell.FakeShell)
839+
840+
var output []parser.ScriptDetail
841+
if err := json.Unmarshal([]byte(fakeShell.OutLines[0]), &output); err != nil {
842+
t.Fatalf("failed to parse json output: %v", err)
843+
}
844+
845+
// Verify nil values are converted to empty arrays
846+
if output[0].Comments == nil {
847+
t.Error("Comments should not be nil in JSON output")
848+
}
849+
if output[0].Commands == nil {
850+
t.Error("Commands should not be nil in JSON output")
851+
}
852+
}

core/parser/fake_parser.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package parser
22

33
import (
44
"kool-dev/kool/core/builder"
5+
"sort"
56
"strings"
67
)
78

@@ -11,10 +12,13 @@ type FakeParser struct {
1112
TargetFiles []string
1213
CalledParse bool
1314
CalledParseAvailableScripts bool
15+
CalledParseAvailableDetails bool
1416
MockParsedCommands map[string][]builder.Command
1517
MockParseError map[string]error
1618
MockScripts []string
19+
MockScriptDetails []ScriptDetail
1720
MockParseAvailableScriptsError error
21+
MockParseAvailableDetailsError error
1822
}
1923

2024
// AddLookupPath implements fake AddLookupPath behavior
@@ -49,3 +53,27 @@ func (f *FakeParser) ParseAvailableScripts(filter string) (scripts []string, err
4953
err = f.MockParseAvailableScriptsError
5054
return
5155
}
56+
57+
// ParseAvailableScriptsDetails implements fake ParseAvailableScriptsDetails behavior
58+
func (f *FakeParser) ParseAvailableScriptsDetails(filter string) (details []ScriptDetail, err error) {
59+
f.CalledParseAvailableDetails = true
60+
61+
if filter == "" {
62+
details = append(details, f.MockScriptDetails...)
63+
} else {
64+
for _, detail := range f.MockScriptDetails {
65+
if strings.HasPrefix(detail.Name, filter) {
66+
details = append(details, detail)
67+
}
68+
}
69+
}
70+
71+
if len(details) > 1 {
72+
sort.Slice(details, func(i, j int) bool {
73+
return details[i].Name < details[j].Name
74+
})
75+
}
76+
77+
err = f.MockParseAvailableDetailsError
78+
return
79+
}

core/parser/parser.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type Parser interface {
1515
AddLookupPath(string) error
1616
Parse(string) ([]builder.Command, error)
1717
ParseAvailableScripts(string) ([]string, error)
18+
ParseAvailableScriptsDetails(string) ([]ScriptDetail, error)
1819
}
1920

2021
// DefaultParser implements all default behavior for using kool.yml files.
@@ -142,3 +143,50 @@ func (p *DefaultParser) ParseAvailableScripts(filter string) (scripts []string,
142143

143144
return
144145
}
146+
147+
// ParseAvailableScriptsDetails parses all available scripts with details
148+
func (p *DefaultParser) ParseAvailableScriptsDetails(filter string) (details []ScriptDetail, err error) {
149+
var (
150+
koolFile string
151+
parsedFile *KoolYaml
152+
found map[string]ScriptDetail
153+
keys []string
154+
)
155+
156+
if len(p.targetFiles) == 0 {
157+
err = errors.New("kool.yml not found")
158+
return
159+
}
160+
161+
found = make(map[string]ScriptDetail)
162+
163+
for _, koolFile = range p.targetFiles {
164+
if parsedFile, err = ParseKoolYamlWithDetails(koolFile); err != nil {
165+
return
166+
}
167+
168+
for name, detail := range parsedFile.ScriptDetails {
169+
if _, exists := found[name]; exists {
170+
continue
171+
}
172+
if filter != "" && !strings.HasPrefix(name, filter) {
173+
continue
174+
}
175+
found[name] = detail
176+
}
177+
}
178+
179+
for name := range found {
180+
keys = append(keys, name)
181+
}
182+
if len(keys) == 0 {
183+
return
184+
}
185+
186+
sort.Strings(keys)
187+
for _, name := range keys {
188+
details = append(details, found[name])
189+
}
190+
191+
return
192+
}

core/parser/parser_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,34 @@ func TestParserParseAvailableScriptsFilter(t *testing.T) {
150150
t.Error("failed to get filtered scripts from kool.yml")
151151
}
152152
}
153+
154+
func TestParserParseAvailableScriptsDetails(t *testing.T) {
155+
var (
156+
p Parser = NewParser()
157+
details []ScriptDetail
158+
err error
159+
)
160+
161+
if _, err = p.ParseAvailableScriptsDetails(""); err == nil {
162+
t.Error("expecting 'kool.yml not found' error, got none")
163+
}
164+
165+
if err != nil && err.Error() != "kool.yml not found" {
166+
t.Errorf("expecting error 'kool.yml not found', got '%s'", err.Error())
167+
}
168+
169+
workDir, _ := os.Getwd()
170+
_ = p.AddLookupPath(path.Join(workDir, "testing_files"))
171+
172+
if details, err = p.ParseAvailableScriptsDetails(""); err != nil {
173+
t.Errorf("unexpected error; error: %s", err)
174+
}
175+
176+
if len(details) != 1 || details[0].Name != "testing" {
177+
t.Error("failed to get script details from kool.yml")
178+
}
179+
180+
if len(details[0].Commands) != 1 || details[0].Commands[0] != "echo testing" {
181+
t.Errorf("unexpected command details: %v", details[0].Commands)
182+
}
183+
}

0 commit comments

Comments
 (0)