Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions apps/cnquery/cmd/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,19 +152,16 @@ func (c *cnqueryPlugin) RunQuery(conf *run.RunQueryConfig, runtime *providers.Ru
// m.StoreRecording(viper.GetString("record-file"))
}

shellOptions := []shell.ShellOption{}
shellOptions = append(shellOptions, shell.WithOnCloseListener(onCloseHandler))
shellOptions := []shell.Option{}
shellOptions = append(shellOptions, shell.WithOnClose(onCloseHandler))
shellOptions = append(shellOptions, shell.WithFeatures(conf.Features))
shellOptions = append(shellOptions, shell.WithOutput(out))

if upstreamConfig != nil {
shellOptions = append(shellOptions, shell.WithUpstreamConfig(upstreamConfig))
}

sh, err := shell.New(asset.Runtime, shellOptions...)
if err != nil {
return errors.Wrap(err, "failed to initialize the shell")
}
sh := shell.NewShell(asset.Runtime, shellOptions...)
defer func() {
// prevent the recording from being closed multiple times
err = asset.Runtime.SetRecording(recording.Null{})
Expand Down
34 changes: 23 additions & 11 deletions apps/cnquery/cmd/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,19 +159,31 @@ func StartShell(runtime *providers.Runtime, conf *ShellConfig) error {
providers.Coordinator.Shutdown()
}

shellOptions := []shell.ShellOption{}
shellOptions = append(shellOptions, shell.WithOnCloseListener(onCloseHandler))
shellOptions = append(shellOptions, shell.WithFeatures(conf.Features))
shellOptions = append(shellOptions, shell.WithUpstreamConfig(conf.UpstreamConfig))

sh, err := shell.New(connectAsset.Runtime, shellOptions...)
if err != nil {
log.Error().Err(err).Msg("failed to initialize interactive shell")
}
// Create shell theme with custom welcome message if provided
shellTheme := shell.DefaultShellTheme
if conf.WelcomeMessage != "" {
sh.Theme.Welcome = conf.WelcomeMessage
// Create a copy with custom welcome message
customTheme := *shellTheme
customTheme.Welcome = conf.WelcomeMessage
shellTheme = &customTheme
}

// Create and run the new Bubble Tea shell
sh := shell.NewShell(
connectAsset.Runtime,
shell.WithOnClose(onCloseHandler),
shell.WithFeatures(conf.Features),
shell.WithUpstreamConfig(conf.UpstreamConfig),
shell.WithTheme(shellTheme),
)

if err := sh.RunWithCommand(conf.Command); err != nil {
if err == shell.ErrNotTTY {
log.Fatal().Msg("shell requires an interactive terminal (TTY)")
}
log.Error().Err(err).Msg("shell error")
return err
}
sh.RunInteractive(conf.Command)

return nil
}
74 changes: 43 additions & 31 deletions cli/shell/completer.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,65 +4,77 @@
package shell

import (
"runtime"
"strings"

"github.com/c-bata/go-prompt"
"go.mondoo.com/cnquery/v12"
"go.mondoo.com/cnquery/v12/mqlc"
"go.mondoo.com/cnquery/v12/providers-sdk/v1/resources"
)

var completerSeparator = string([]byte{'.', ' '})
// Suggestion represents a completion suggestion for the shell
type Suggestion struct {
Text string // The completion text
Description string // Description shown in popup
}

// Completer is an auto-complete helper for the shell
type Completer struct {
schema resources.ResourcesSchema
features cnquery.Features
queryPrefix func() string
forceCompletions bool
schema resources.ResourcesSchema
features cnquery.Features
queryPrefix func() string
}

// NewCompleter creates a new Mondoo completer object
func NewCompleter(schema resources.ResourcesSchema, features cnquery.Features, queryPrefix func() string) *Completer {
return &Completer{
schema: schema,
features: features,
queryPrefix: queryPrefix,
forceCompletions: features.IsActive(cnquery.ForceShellCompletion),
schema: schema,
features: features,
queryPrefix: queryPrefix,
}
}

// CompletePrompt provides suggestions
func (c *Completer) CompletePrompt(doc prompt.Document) []prompt.Suggest {
if runtime.GOOS == "windows" && !c.forceCompletions {
// builtinCommands are shell commands that should appear in completions
var builtinCommands = []Suggestion{
{Text: "exit", Description: "Exit the shell"},
{Text: "quit", Description: "Exit the shell"},
{Text: "help", Description: "Show available resources"},
{Text: "clear", Description: "Clear the screen"},
}

// Complete returns suggestions for the given input text
func (c *Completer) Complete(text string) []Suggestion {
if text == "" {
return nil
}
if doc.TextBeforeCursor() == "" {
return []prompt.Suggest{}

var suggestions []Suggestion

// Check for matching built-in commands first (only at the start of input)
if c.queryPrefix == nil || c.queryPrefix() == "" {
for _, cmd := range builtinCommands {
if strings.HasPrefix(cmd.Text, text) {
suggestions = append(suggestions, cmd)
}
}
}

// Get MQL suggestions
var query string
if c.queryPrefix != nil {
query = c.queryPrefix()
}
query += doc.TextBeforeCursor()
query += text

bundle, _ := mqlc.Compile(query, nil, mqlc.NewConfig(c.schema, c.features))
if bundle == nil || len(bundle.Suggestions) == 0 {
return []prompt.Suggest{}
}

res := make([]prompt.Suggest, len(bundle.Suggestions))
for i := range bundle.Suggestions {
cur := bundle.Suggestions[i]
res[i] = prompt.Suggest{
Text: cur.Field,
Description: cur.Title,
if bundle != nil && len(bundle.Suggestions) > 0 {
for i := range bundle.Suggestions {
cur := bundle.Suggestions[i]
suggestions = append(suggestions, Suggestion{
Text: cur.Field,
Description: cur.Title,
})
}
}

return res

// Alternatively we can decide to let prompt filter this list of words for us:
// return prompt.FilterHasPrefix(suggest, doc.GetWordBeforeCursor(), true)
return suggestions
}
99 changes: 99 additions & 0 deletions cli/shell/filtered_schema.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright (c) Mondoo, Inc.
// SPDX-License-Identifier: BUSL-1.1

package shell

import (
"go.mondoo.com/cnquery/v12/providers-sdk/v1/resources"
)

// FilteredSchema wraps a ResourcesSchema and filters resources to only show
// those from connected providers or cross-provider resources (like core).
type FilteredSchema struct {
schema resources.ResourcesSchema
connectedProviders map[string]struct{}
}

// Providers that are always available regardless of connection
var alwaysAvailableProviders = []string{
"go.mondoo.com/cnquery/v9/providers/core",
"go.mondoo.com/cnquery/v9/providers/network",
}

// NewFilteredSchema creates a new FilteredSchema that only exposes resources
// from the specified providers. The core and network providers are always included
// as they are available regardless of the connection type.
func NewFilteredSchema(schema resources.ResourcesSchema, providerIDs []string) *FilteredSchema {
providers := make(map[string]struct{}, len(providerIDs)+len(alwaysAvailableProviders))
for _, id := range providerIDs {
providers[id] = struct{}{}
}
// Always include core and network providers
for _, id := range alwaysAvailableProviders {
providers[id] = struct{}{}
}

return &FilteredSchema{
schema: schema,
connectedProviders: providers,
}
}

// Lookup returns the resource info for a given resource name.
// It returns nil if the resource is not from a connected provider.
func (f *FilteredSchema) Lookup(resource string) *resources.ResourceInfo {
info := f.schema.Lookup(resource)
if info == nil {
return nil
}
if !f.isProviderConnected(info.Provider) {
return nil
}
return info
}

// LookupField returns the resource info and field for a given resource and field name.
func (f *FilteredSchema) LookupField(resource string, field string) (*resources.ResourceInfo, *resources.Field) {
info, fieldInfo := f.schema.LookupField(resource, field)
if info == nil {
return nil, nil
}
if !f.isProviderConnected(info.Provider) {
return nil, nil
}
return info, fieldInfo
}

// FindField finds a field in a resource, including embedded fields.
func (f *FilteredSchema) FindField(resource *resources.ResourceInfo, field string) (resources.FieldPath, []*resources.Field, bool) {
return f.schema.FindField(resource, field)
}

// AllResources returns only resources from connected providers.
func (f *FilteredSchema) AllResources() map[string]*resources.ResourceInfo {
all := f.schema.AllResources()
filtered := make(map[string]*resources.ResourceInfo, len(all))

for name, info := range all {
if f.isProviderConnected(info.Provider) {
filtered[name] = info
}
}

return filtered
}

// AllDependencies returns all provider dependencies.
func (f *FilteredSchema) AllDependencies() map[string]*resources.ProviderInfo {
return f.schema.AllDependencies()
}

// isProviderConnected checks if a provider is in the connected providers set.
// Empty provider string means cross-provider resource, which is always included.
func (f *FilteredSchema) isProviderConnected(provider string) bool {
if provider == "" {
return true
}
_, ok := f.connectedProviders[provider]
return ok
}
Loading
Loading