feat: table rendering, cron schedules, and assets command

- Refactor table display using tablewriter with box-drawing characters and proper Unicode width handling
- Add --as-cron flag to register cron schedules instead of executing immediately
- Implement new 'osmedeus assets' command for asset querying with filtering and statistics
- Enhance workflow parameter display with categorization and per-module summaries
- Improve column width handling and parameter documentation
This commit is contained in:
j3ssie
2026-02-17 15:19:49 +07:00
parent d01bd326df
commit 8d413aecb9
31 changed files with 1438 additions and 425 deletions
+193
View File
@@ -0,0 +1,193 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/j3ssie/osmedeus/v5/internal/config"
"github.com/j3ssie/osmedeus/v5/internal/database"
"github.com/j3ssie/osmedeus/v5/internal/terminal"
"github.com/spf13/cobra"
)
var (
assetsWorkspace string
assetsSource string
assetsType string
assetsStats bool
assetsLimit int
assetsOffset int
assetsColumns string
assetsExcludeColumns string
assetsAll bool
)
var assetsCmd = &cobra.Command{
Use: "assets [search]",
Aliases: []string{"asset"},
Short: "Query and list discovered assets",
Long: UsageAssets(),
RunE: runAssets,
}
func init() {
assetsCmd.Flags().StringVarP(&assetsWorkspace, "workspace", "w", "", "filter by workspace name")
assetsCmd.Flags().StringVar(&assetsSource, "source", "", "filter by source field (e.g., httpx, subfinder)")
assetsCmd.Flags().StringVar(&assetsType, "type", "", "filter by asset_type field (e.g., web, subdomain)")
assetsCmd.Flags().BoolVar(&assetsStats, "stats", false, "show asset statistics (unique technologies, sources, remarks, types)")
assetsCmd.Flags().IntVar(&assetsLimit, "limit", 50, "maximum number of records to return")
assetsCmd.Flags().IntVar(&assetsOffset, "offset", 0, "number of records to skip (for pagination)")
assetsCmd.Flags().StringVar(&assetsColumns, "columns", "", "comma-separated columns to display")
assetsCmd.Flags().StringVar(&assetsExcludeColumns, "exclude-columns", "", "comma-separated columns to exclude from output")
assetsCmd.Flags().BoolVar(&assetsAll, "all", false, "show all columns including hidden ones (id, timestamps)")
}
func runAssets(cmd *cobra.Command, args []string) error {
if disableDB {
return fmt.Errorf("assets command unavailable: --disable-db flag is set")
}
cfg := config.Get()
if cfg == nil {
return fmt.Errorf("configuration not loaded")
}
// Connect to database
_, err := database.Connect(cfg)
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
defer func() { _ = database.Close() }()
ctx := context.Background()
if err := database.Migrate(ctx); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
if assetsStats {
return runAssetsStats(ctx)
}
return runAssetsList(ctx, args)
}
func runAssetsList(ctx context.Context, args []string) error {
// Validate pagination
if assetsLimit <= 0 {
assetsLimit = 50
}
if assetsLimit > 10000 {
assetsLimit = 10000
}
if assetsOffset < 0 {
assetsOffset = 0
}
// Build filters
filters := make(map[string]string)
if assetsSource != "" {
filters["source"] = assetsSource
}
if assetsType != "" {
filters["asset_type"] = assetsType
}
if assetsWorkspace != "" {
filters["workspace"] = assetsWorkspace
}
// Search term from positional arg
search := ""
if len(args) > 0 {
search = args[0]
}
records, err := database.GetTableRecords(ctx, "assets", assetsOffset, assetsLimit, filters, search, database.AssetHeavyColumns)
if err != nil {
return fmt.Errorf("failed to get assets: %w", err)
}
// JSON output
if globalJSON {
jsonBytes, err := json.Marshal(records.Records)
if err != nil {
return fmt.Errorf("failed to format records: %w", err)
}
fmt.Println(string(jsonBytes))
return nil
}
// Table output
printer := terminal.NewPrinter()
requestedColumns := parseColumns(assetsColumns)
columns := getEffectiveColumns("assets", requestedColumns, assetsAll)
excludeColumns := parseExcludeColumns(assetsExcludeColumns)
hideDefaultColumns := !assetsAll && len(requestedColumns) == 0 && tableDefaultColumns["assets"] == nil
startRecord := records.Offset + 1
endRecord := records.Offset + assetsLimit
if endRecord > records.TotalCount {
endRecord = records.TotalCount
}
if records.TotalCount == 0 {
startRecord = 0
}
printer.Info("Assets")
fmt.Printf("Showing records %d-%d of %d\n\n", startRecord, endRecord, records.TotalCount)
renderTableWithTablewriter("assets", records.Records, columns, globalWidth, hideDefaultColumns, excludeColumns)
if records.TotalCount > endRecord {
nextOffset := records.Offset + records.Limit
printer.Info("Next page: osmedeus assets --offset %d --limit %d", nextOffset, assetsLimit)
}
return nil
}
func runAssetsStats(ctx context.Context) error {
stats, err := database.GetAssetStats(ctx, assetsWorkspace)
if err != nil {
return fmt.Errorf("failed to get asset stats: %w", err)
}
// JSON output
if globalJSON {
jsonBytes, err := json.Marshal(stats)
if err != nil {
return fmt.Errorf("failed to format stats: %w", err)
}
fmt.Println(string(jsonBytes))
return nil
}
// Table output
printer := terminal.NewPrinter()
if assetsWorkspace != "" {
printer.Info("Asset Statistics (workspace: %s)", assetsWorkspace)
} else {
printer.Info("Asset Statistics")
}
fmt.Println()
printStatCategory("Technologies", stats.Technologies)
printStatCategory("Sources", stats.Sources)
printStatCategory("Remarks", stats.Remarks)
printStatCategory("Asset Types", stats.AssetTypes)
return nil
}
func printStatCategory(name string, items []string) {
fmt.Printf("%s (%d):\n", terminal.Bold(name), len(items))
if len(items) == 0 {
fmt.Println(" (none)")
} else {
for _, item := range items {
fmt.Printf(" %s %s\n", terminal.SymbolBullet, strings.TrimSpace(item))
}
}
fmt.Println()
}
+3 -113
View File
@@ -7,13 +7,11 @@ import (
"net/url"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"syscall"
"time"
"github.com/charmbracelet/glamour"
"github.com/j3ssie/osmedeus/v5/internal/client"
"github.com/j3ssie/osmedeus/v5/internal/terminal"
"github.com/spf13/cobra"
@@ -121,7 +119,7 @@ func outputJSON(data interface{}) error {
return nil
}
// printClientTable prints data in a styled markdown table format
// printClientTable prints data using the same tablewriter renderer as `db ls`
func printClientTable(tableName string, data interface{}, columns []string, pagination client.Pagination, recordCount int) {
printer := terminal.NewPrinter()
printer.Info("Table: %s", tableName)
@@ -134,115 +132,7 @@ func printClientTable(tableName string, data interface{}, columns []string, pagi
}
fmt.Printf("Showing records %d-%d of %d\n\n", startRecord, endRecord, pagination.Total)
// Format as markdown table
tableStr := formatClientMarkdownTable(data, columns, globalWidth)
// Render with glamour
renderer, err := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(0),
)
if err == nil {
rendered, renderErr := renderer.Render(tableStr)
if renderErr == nil {
fmt.Print(rendered)
} else {
fmt.Println(tableStr)
}
} else {
fmt.Println(tableStr)
}
}
// formatClientMarkdownTable formats records as a markdown table
func formatClientMarkdownTable(records interface{}, columns []string, maxWidth int) string {
// Convert records to []map[string]interface{}
jsonBytes, _ := json.Marshal(records)
var data []map[string]interface{}
if err := json.Unmarshal(jsonBytes, &data); err != nil {
return "No records found."
}
if len(data) == 0 {
return "No records found."
}
// Use specified columns or extract from first record
var headers []string
if len(columns) > 0 {
headers = columns
} else {
for key := range data[0] {
headers = append(headers, key)
}
sort.Strings(headers)
}
// Build markdown table
var sb strings.Builder
// Header row
sb.WriteString("| ")
sb.WriteString(strings.Join(headers, " | "))
sb.WriteString(" |\n")
// Separator row
sb.WriteString("|")
for range headers {
sb.WriteString(" --- |")
}
sb.WriteString("\n")
// Data rows
for _, row := range data {
sb.WriteString("| ")
for i, h := range headers {
val := formatClientTableValue(row[h], maxWidth, h)
if i > 0 {
sb.WriteString(" | ")
}
sb.WriteString(val)
}
sb.WriteString(" |\n")
}
return sb.String()
}
// formatClientTableValue converts a value to string for markdown display
func formatClientTableValue(v interface{}, maxWidth int, columnName string) string {
if v == nil {
return ""
}
var s string
switch val := v.(type) {
case string:
// Escape pipe characters and newlines
s = strings.ReplaceAll(val, "|", "\\|")
s = strings.ReplaceAll(s, "\n", " ")
case map[string]interface{}, []interface{}:
// Compact JSON for complex types
b, _ := json.Marshal(val)
s = string(b)
default:
s = fmt.Sprintf("%v", val)
}
// Apply width limit
if maxWidth > 0 && len(s) > maxWidth {
if maxWidth > 3 {
s = s[:maxWidth-3] + "..."
} else {
s = s[:maxWidth]
}
}
// Colorize status column
if columnName == "status" {
return terminal.ColorizeStatus(s)
}
return s
renderTableWithTablewriter(tableName, data, columns, globalWidth, false, nil)
}
// validFetchTables lists all valid table names for the fetch command
@@ -256,7 +146,7 @@ var clientTableDefaultColumns = map[string][]string{
"runs": {"run_uuid", "workflow_name", "target", "status", "completed_steps", "total_steps"},
"step_results": {"step_name", "step_type", "status", "duration_ms", "command"},
"artifacts": {"name", "artifact_path", "artifact_type", "size_bytes"},
"assets": {"asset_value", "host_ip", "title", "status_code"},
"assets": {"asset_value", "url", "status_code", "content_length", "title"},
"event_logs": {"topic", "source", "processed", "workspace"},
"schedules": {"name", "workflow_name", "trigger_type", "schedule", "is_enabled"},
"workspaces": {"name", "data_source", "total_assets"},
+112 -63
View File
@@ -11,10 +11,12 @@ import (
"syscall"
"time"
"github.com/charmbracelet/glamour"
"github.com/j3ssie/osmedeus/v5/internal/config"
"github.com/j3ssie/osmedeus/v5/internal/database"
"github.com/j3ssie/osmedeus/v5/internal/terminal"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/renderer"
"github.com/olekukonko/tablewriter/tw"
"github.com/spf13/cobra"
)
@@ -45,13 +47,24 @@ var tableDefaultColumns = map[string][]string{
"runs": {"run_uuid", "workflow_name", "target", "workspace", "trigger_type", "status", "completed_steps", "total_steps"},
"step_results": {"step_name", "step_type", "status", "duration_ms", "command"},
"artifacts": {"name", "path", "type", "size_bytes", "line_count"},
"assets": {"asset_value", "url", "status_code", "content_length", "title"},
"assets": {"asset_value", "status_code", "title", "tech", "host_ip", "source", "asset_type", "url"},
"event_logs": {"topic", "source", "processed", "data_type", "workspace", "data"},
"schedules": {"name", "workflow_name", "workflow_kind", "target", "trigger_type", "schedule", "is_enabled", "run_count"},
"workspaces": {"name", "data_source", "total_assets", "total_ips", "total_vulns", "risk_score"},
"vulnerabilities": {"vuln_title", "severity", "confidence", "asset_value", "last_seen_at", "workspace"},
}
// tableColumnMinWidths defines per-column minimum widths for specific tables (column_name → min_width)
var tableColumnMinWidths = map[string]map[string]int{
"schedules": {
"name": 30,
"schedule": 20,
},
"assets": {
"title": 30,
},
}
// dbCmd - parent command for database management
var dbCmd = &cobra.Command{
Use: "db",
@@ -614,24 +627,8 @@ func listTableRecordsOnce(ctx context.Context, cfg *config.Config, printer *term
printer.Info("Table: %s", records.Table)
fmt.Printf("Showing records %d-%d of %d\n\n", startRecord, endRecord, records.TotalCount)
// Output as markdown table
tableStr := formatAsMarkdownTable(records.Records, columns, globalWidth, hideDefaultColumns, excludeColumns)
// Render with glamour for styled markdown table
renderer, err := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(0),
)
if err == nil {
rendered, renderErr := renderer.Render(tableStr)
if renderErr == nil {
fmt.Print(rendered)
} else {
fmt.Println(tableStr)
}
} else {
fmt.Println(tableStr)
}
// Render table using tablewriter
renderTableWithTablewriter(dbTable, records.Records, columns, globalWidth, hideDefaultColumns, excludeColumns)
// Show pagination hints
if records.TotalCount > endRecord {
@@ -722,23 +719,24 @@ func isHiddenColumn(col string) bool {
return false
}
// formatAsMarkdownTable formats records as a markdown table
func formatAsMarkdownTable(records interface{}, columns []string, maxWidth int, hideDefaultColumns bool, excludeColumns map[string]bool) string {
// renderTableWithTablewriter renders records directly to stdout using tablewriter
func renderTableWithTablewriter(tableName string, records interface{}, columns []string, maxWidth int, hideDefaultColumns bool, excludeColumns map[string]bool) {
// Convert records to []map[string]interface{}
jsonBytes, _ := json.Marshal(records)
var data []map[string]interface{}
if err := json.Unmarshal(jsonBytes, &data); err != nil {
return "No records found."
fmt.Println("No records found.")
return
}
if len(data) == 0 {
return "No records found."
fmt.Println("No records found.")
return
}
// Get headers (all keys or selected columns)
var headers []string
if len(columns) > 0 {
// Filter out excluded columns from specified columns
for _, col := range columns {
if !excludeColumns[col] {
headers = append(headers, col)
@@ -746,11 +744,9 @@ func formatAsMarkdownTable(records interface{}, columns []string, maxWidth int,
}
} else {
for key := range data[0] {
// Skip hidden columns if hideDefaultColumns is true
if hideDefaultColumns && isHiddenColumn(key) {
continue
}
// Skip excluded columns
if excludeColumns[key] {
continue
}
@@ -759,39 +755,75 @@ func formatAsMarkdownTable(records interface{}, columns []string, maxWidth int,
sort.Strings(headers)
}
// Build markdown table
var sb strings.Builder
// Header row
sb.WriteString("| ")
sb.WriteString(strings.Join(headers, " | "))
sb.WriteString(" |\n")
// Separator row
sb.WriteString("|")
for range headers {
sb.WriteString(" --- |")
// Build tablewriter options
opts := []tablewriter.Option{
tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{
Borders: tw.Border{
Left: tw.Off,
Right: tw.Off,
Top: tw.Off,
Bottom: tw.Off,
},
Settings: tw.Settings{
Separators: tw.Separators{
BetweenColumns: tw.On,
BetweenRows: tw.Off,
},
Lines: tw.Lines{
ShowHeaderLine: tw.On,
ShowTop: tw.Off,
ShowBottom: tw.Off,
ShowFooterLine: tw.Off,
},
},
Symbols: tw.NewSymbols(tw.StyleLight),
})),
tablewriter.WithHeaderAlignment(tw.AlignLeft),
tablewriter.WithRowAlignment(tw.AlignLeft),
tablewriter.WithHeaderAutoFormat(tw.Off),
tablewriter.WithHeaderAutoWrap(tw.WrapNone),
tablewriter.WithTrimSpace(tw.On),
}
if maxWidth > 0 {
opts = append(opts, tablewriter.WithMaxWidth(maxWidth))
}
sb.WriteString("\n")
// Data rows
for _, row := range data {
sb.WriteString("| ")
// Apply per-column minimum widths if defined for this table
if colWidths, ok := tableColumnMinWidths[tableName]; ok {
widths := tw.NewMapper[int, int]()
for i, h := range headers {
val := formatTableValue(row[h], maxWidth)
if i > 0 {
sb.WriteString(" | ")
if minW, exists := colWidths[h]; exists {
widths[i] = minW
}
sb.WriteString(val)
}
sb.WriteString(" |\n")
if len(widths) > 0 {
opts = append(opts, tablewriter.WithColumnWidths(widths))
}
}
return sb.String()
table := tablewriter.NewTable(os.Stdout, opts...)
// Convert headers to []any for variadic call
headerArgs := make([]any, len(headers))
for i, h := range headers {
headerArgs[i] = h
}
table.Header(headerArgs...)
// Add data rows
for _, row := range data {
rowArgs := make([]any, len(headers))
for i, h := range headers {
rowArgs[i] = formatTableValue(row[h], h)
}
_ = table.Append(rowArgs...)
}
_ = table.Render()
}
// formatTableValue converts a value to string for markdown display
func formatTableValue(v interface{}, maxWidth int) string {
// formatTableValue converts a value to string for table display and applies column-specific coloring
func formatTableValue(v interface{}, columnName string) string {
if v == nil {
return ""
}
@@ -799,24 +831,41 @@ func formatTableValue(v interface{}, maxWidth int) string {
var s string
switch val := v.(type) {
case string:
// Escape pipe characters and newlines
s = strings.ReplaceAll(val, "|", "\\|")
s = strings.ReplaceAll(s, "\n", " ")
case map[string]interface{}, []interface{}:
// Compact JSON for complex types
s = strings.ReplaceAll(val, "\n", " ")
case []interface{}:
// Format arrays as comma-separated values without brackets/quotes
parts := make([]string, 0, len(val))
for _, item := range val {
parts = append(parts, fmt.Sprintf("%v", item))
}
s = strings.Join(parts, ", ")
case map[string]interface{}:
b, _ := json.Marshal(val)
s = string(b)
default:
s = fmt.Sprintf("%v", val)
}
// Apply width limit
if maxWidth > 0 && len(s) > maxWidth {
if maxWidth > 3 {
s = s[:maxWidth-3] + "..."
} else {
s = s[:maxWidth]
}
// Apply column-specific coloring
switch columnName {
case "status":
s = terminal.ColorizeStatus(s)
case "trigger_type":
s = terminal.ColorizeTriggerType(s)
case "is_enabled":
s = terminal.ColorizeEnabled(s)
case "workflow_kind":
s = terminal.ColorizeWorkflowKind(s)
case "schedule":
s = terminal.ColorizeSchedule(s)
case "status_code":
s = terminal.ColorizeStatusCode(s)
case "source":
s = terminal.ColorizeSource(s)
case "asset_type":
s = terminal.ColorizeAssetType(s)
case "tech":
s = terminal.Gray(s)
}
return s
+2 -1
View File
@@ -285,7 +285,7 @@ func init() {
// Global flags available to all subcommands
rootCmd.PersistentFlags().BoolVar(&globalForce, "force", false, "skip confirmation prompts and force operations")
rootCmd.PersistentFlags().BoolVar(&globalJSON, "json", false, "output in JSON format")
rootCmd.PersistentFlags().IntVar(&globalWidth, "width", 80, "max column width for table display (0 = no limit)")
rootCmd.PersistentFlags().IntVar(&globalWidth, "width", 70, "max column width for table display (0 = no limit)")
// Suppress usage display and default error output (we handle errors in Execute())
rootCmd.SilenceUsage = true
@@ -318,6 +318,7 @@ func init() {
rootCmd.AddCommand(evalCmd)
rootCmd.AddCommand(clientCmd)
rootCmd.AddCommand(uninstallCmd)
rootCmd.AddCommand(assetsCmd)
}
// installRequiredBinaries installs all required binaries from the registry.
+137 -3
View File
@@ -32,6 +32,7 @@ import (
"github.com/j3ssie/osmedeus/v5/internal/logger"
"github.com/j3ssie/osmedeus/v5/internal/parser"
"github.com/j3ssie/osmedeus/v5/internal/terminal"
"github.com/robfig/cron/v3"
"github.com/spf13/cobra"
"go.uber.org/zap"
)
@@ -67,6 +68,7 @@ var (
queueRunProcess bool
webhookRun bool
webhookAuthKey string
cronSchedule string
// Chunk mode flags
chunkSize int
@@ -157,6 +159,9 @@ func init() {
// Webhook flags
runCmd.Flags().BoolVar(&webhookRun, "as-webhook", false, "register a webhook trigger for this run instead of executing immediately")
runCmd.Flags().StringVar(&webhookAuthKey, "webhook-auth-key", "", "optional authentication key for the webhook trigger")
// Cron schedule flag
runCmd.Flags().StringVar(&cronSchedule, "as-cron", "", "create a cron schedule instead of executing (e.g., '0 2 * * *' for daily at 2am)")
}
// captureExplicitFlags records which CLI flags were explicitly set by the user
@@ -531,6 +536,11 @@ func runRun(cmd *cobra.Command, args []string) error {
return runWebhookRun(cfg, allTargets, printer)
}
// Handle cron schedule creation mode
if cronSchedule != "" {
return runCronSchedule(cfg, allTargets, printer)
}
// Handle queue mode
if queueRun {
return runQueuedRun(cfg, allTargets, printer)
@@ -937,6 +947,33 @@ func executeRunForTargetWithContext(ctx context.Context, workflow *core.Workflow
fmt.Printf(" %s baseThreads: %s\n", terminal.Gray("│"), terminal.Gray(fmt.Sprintf("%d", baseThreads)))
fmt.Printf(" %s Today: %s\n", terminal.Gray("│"), terminal.Gray(time.Now().Format("2006-01-02")))
fmt.Println(terminal.Yellow(separator))
// Show parameter tables in dry-run
if workflow.IsFlow() && loader != nil && len(workflow.Modules) > 0 {
fmt.Println()
fmt.Println("◆ " + terminal.Bold("Module Parameters:"))
for _, m := range workflow.Modules {
if m.Path == "" {
continue
}
mod, err := loader.LoadWorkflow(m.Path)
if err != nil {
continue
}
mToggle, mSpeed, _, _ := core.CategorizeParams(mod.Params)
if len(mToggle)+len(mSpeed) == 0 {
continue
}
fmt.Println()
fmt.Println(" ◼ " + terminal.Bold(m.Name) + ":")
printSpeedControlParams(mSpeed)
printToggleParams(mToggle)
}
} else if len(workflow.Params) > 0 {
toggle, speed, _, _ := categorizeParams(workflow.Params)
printSpeedControlParams(speed)
printToggleParams(toggle)
}
fmt.Println()
}
@@ -1485,18 +1522,32 @@ func printResultSummary(result *core.WorkflowResult) {
printer.KeyValue("Status", terminal.StatusBadge(string(result.Status)))
printer.KeyValue("Duration", formatDuration(result.EndTime.Sub(result.StartTime)))
if len(result.ModuleResults) > 0 {
fmt.Println()
fmt.Println(terminal.ResultSymbol() + " " + terminal.Bold("Module Results:"))
var rows [][]string
for _, mod := range result.ModuleResults {
rows = append(rows, []string{
terminal.StepSymbol(string(mod.Status)),
mod.ModuleName,
terminal.Magenta(formatDuration(mod.Duration)),
})
}
printMarkdownTable([]string{terminal.Bold("Status"), terminal.Bold("Module"), terminal.Bold("Duration")}, rows, "clc")
}
if len(result.Steps) > 0 {
fmt.Println()
fmt.Println(terminal.ResultSymbol() + " " + terminal.Bold("Step Results:"))
var rows [][]string
for _, step := range result.Steps {
rows = append(rows, []string{
terminal.PaddedStepSymbol(string(step.Status)),
terminal.StepSymbol(string(step.Status)),
step.StepName,
formatDuration(step.Duration),
terminal.Magenta(formatDuration(step.Duration)),
})
}
printMarkdownTable([]string{"Status", "Step", "Duration"}, rows)
printMarkdownTable([]string{terminal.Bold("Status"), terminal.Bold("Step"), terminal.Bold("Duration")}, rows, "clc")
}
if len(result.Artifacts) > 0 {
@@ -2089,6 +2140,89 @@ func runWebhookRun(cfg *config.Config, allTargets []string, printer *terminal.Pr
return nil
}
// runCronSchedule creates cron schedule records in the database instead of executing immediately.
func runCronSchedule(cfg *config.Config, allTargets []string, printer *terminal.Printer) error {
// Determine workflow name and kind
workflowName := flowName
workflowKind := "flow"
if workflowName == "" && len(moduleNames) > 0 {
workflowName = moduleNames[0]
workflowKind = "module"
}
if workflowName == "" {
return fmt.Errorf("workflow name required (use -f or -m)")
}
// Validate cron expression
cronParser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
schedule, err := cronParser.Parse(cronSchedule)
if err != nil {
return fmt.Errorf("invalid cron expression %q: %w\n Examples: '0 2 * * *' (daily 2am), '0 */6 * * *' (every 6h), '0 0 * * 1' (weekly Monday)", cronSchedule, err)
}
// Compute next run time
nextRun := schedule.Next(time.Now())
// Parse additional params
params := make(map[string]interface{})
for _, flag := range paramFlags {
parts := strings.SplitN(flag, "=", 2)
if len(parts) == 2 {
params[parts[0]] = parts[1]
}
}
ctx := context.Background()
// Connect to database and migrate
_, err = database.Connect(cfg)
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
if err := database.Migrate(ctx); err != nil {
return fmt.Errorf("failed to migrate database: %w", err)
}
printer.Section("Creating Cron Schedules")
var created int
for _, target := range allTargets {
scheduleName := fmt.Sprintf("cron-%s-%s", workflowName, sanitizeTargetForWorkspace(target))
sched, err := database.CreateSchedule(ctx, database.CreateScheduleInput{
Name: scheduleName,
WorkflowName: workflowName,
WorkflowKind: workflowKind,
Target: target,
Params: params,
TriggerType: "cron",
Schedule: cronSchedule,
Enabled: true,
NextRun: &nextRun,
})
if err != nil {
printer.Error("Failed to create schedule for %s: %s", target, err)
continue
}
created++
printer.Success("Schedule created: %s -> %s",
terminal.Green(target),
terminal.Yellow(workflowName))
printer.Info(" ID: %s", terminal.Cyan(sched.ID))
printer.Info(" Schedule: %s", terminal.Cyan(cronSchedule))
printer.Info(" Next run: %s", terminal.Cyan(nextRun.Format(time.RFC3339)))
}
fmt.Println()
printer.Info("Created %d cron schedule(s)", created)
printer.Info("Use '%s' to list schedules", terminal.Cyan("osmedeus db ls --table schedules"))
printer.Info("Run '%s' to activate the scheduler", terminal.Cyan("osmedeus serve"))
return nil
}
func runQueuedRun(cfg *config.Config, allTargets []string, printer *terminal.Printer) error {
// Determine workflow name and kind
workflowName := flowName
+70
View File
@@ -127,6 +127,21 @@ func UsageRun() string {
` + terminal.Green("# Process queued tasks with concurrency") + `
osmedeus run ` + terminal.Yellow("--queue-run") + ` ` + terminal.Yellow("--concurrency") + ` 3
` + terminal.Green("# Register a webhook trigger (no execution)") + `
osmedeus run ` + terminal.Yellow("--as-webhook") + ` ` + terminal.Yellow("-m") + ` recon ` + terminal.Yellow("-t") + ` example.com
` + terminal.Green("# Register a webhook with auth key") + `
osmedeus run ` + terminal.Yellow("--as-webhook") + ` ` + terminal.Yellow("--webhook-auth-key") + ` mykey ` + terminal.Yellow("-f") + ` general ` + terminal.Yellow("-t") + ` example.com
` + terminal.Green("# Create a cron schedule (daily at 2am)") + `
osmedeus run ` + terminal.Yellow("--as-cron") + ` '0 2 * * *' ` + terminal.Yellow("-m") + ` recon ` + terminal.Yellow("-t") + ` example.com
` + terminal.Green("# Create a cron schedule (every 6 hours) for a flow") + `
osmedeus run ` + terminal.Yellow("--as-cron") + ` '0 */6 * * *' ` + terminal.Yellow("-f") + ` general ` + terminal.Yellow("-t") + ` example.com
` + terminal.Green("# Create cron schedules for multiple targets") + `
osmedeus run ` + terminal.Yellow("--as-cron") + ` '0 0 * * 1' ` + terminal.Yellow("-m") + ` recon ` + terminal.Yellow("-T") + ` targets.txt
` + docsFooter()
}
@@ -841,6 +856,12 @@ func UsageAllExamples() string {
` + terminal.Green("# Split into 4 equal chunks and run chunk 0") + `
osmedeus run ` + terminal.Yellow("-m") + ` recon ` + terminal.Yellow("-T") + ` targets.txt ` + terminal.Yellow("--chunk-count") + ` 4 ` + terminal.Yellow("--chunk-part") + ` 0
` + terminal.Green("# Register a webhook trigger") + `
osmedeus run ` + terminal.Yellow("--as-webhook") + ` ` + terminal.Yellow("-m") + ` recon ` + terminal.Yellow("-t") + ` example.com
` + terminal.Green("# Create a cron schedule (daily at 2am)") + `
osmedeus run ` + terminal.Yellow("--as-cron") + ` '0 2 * * *' ` + terminal.Yellow("-m") + ` recon ` + terminal.Yellow("-t") + ` example.com
` + terminal.BoldYellow("★ Function Eval (Powerful Scripting)") + `
` + terminal.Green("# Print markdown file") + `
osmedeus func e 'print_markdown_from_file("README.md")'
@@ -1013,6 +1034,11 @@ func UsageFullExample() string {
` + terminal.Yellow("-D, --distributed-run") + ` Submit run to distributed worker queue
` + terminal.Yellow("--redis-url") + ` Redis connection URL for distributed mode
` + terminal.Cyan(" Schedule & Trigger:") + `
` + terminal.Yellow("--as-webhook") + ` Register a webhook trigger instead of executing
` + terminal.Yellow("--webhook-auth-key") + ` Authentication key for the webhook trigger
` + terminal.Yellow("--as-cron") + ` Create a cron schedule instead of executing (e.g., '0 2 * * *')
` + terminal.BoldYellow("SERVE COMMAND") + ` - Start REST API server
` + terminal.Gray("───────────────────────────────────────────────────────────────────") + `
osmedeus serve [flags]
@@ -1288,6 +1314,50 @@ func UsageUninstall() string {
` + docsFooter()
}
// UsageAssets returns the Long description for the assets command
func UsageAssets() string {
return terminal.BoldCyan("◆ Description") + `
Query and list discovered assets from the database.
A shortcut for ` + terminal.Yellow("osmedeus db ls -t assets") + ` with first-class support
for fuzzy search, source/type filtering, and asset statistics.
` + terminal.BoldCyan("▷ Examples") + `
` + terminal.Green("# List all assets (default columns)") + `
osmedeus assets
` + terminal.Green("# Fuzzy search across asset fields") + `
osmedeus assets example.com
` + terminal.Green("# Filter by workspace") + `
osmedeus assets ` + terminal.Yellow("-w") + ` myworkspace
` + terminal.Green("# Filter by source") + `
osmedeus assets ` + terminal.Yellow("--source") + ` httpx
` + terminal.Green("# Filter by asset type") + `
osmedeus assets ` + terminal.Yellow("--type") + ` web
` + terminal.Green("# Combined filters") + `
osmedeus assets ` + terminal.Yellow("--source") + ` httpx ` + terminal.Yellow("--type") + ` web
` + terminal.Green("# Show asset statistics") + `
osmedeus assets ` + terminal.Yellow("--stats") + `
` + terminal.Green("# Stats filtered by workspace") + `
osmedeus assets ` + terminal.Yellow("--stats") + ` ` + terminal.Yellow("-w") + ` myworkspace
` + terminal.Green("# With pagination") + `
osmedeus assets example.com ` + terminal.Yellow("--limit") + ` 100
` + terminal.Green("# JSON output") + `
osmedeus assets example.com ` + terminal.Yellow("--json") + `
` + terminal.Green("# Custom columns") + `
osmedeus assets ` + terminal.Yellow("--columns") + ` "asset_value,url,status_code"
` + docsFooter()
}
// docsFooter returns the documentation footer
func docsFooter() string {
return terminal.HiCyan("📖 Documentation: ") + terminal.HiWhite(core.DOCS) + "\n"
+6 -6
View File
@@ -201,18 +201,18 @@ var workerStatusCmd = &cobra.Command{
// Determine active headers
headers := resolveWorkerColumns(allHeaders, defaultHeaders, workerColumns, workerExcludeColumns)
// Map headers to column indices and project rows
// Map headers to column indices and build records for tablewriter
colIndices := mapHeaderIndices(allHeaders, headers)
var rows [][]string
var records []map[string]interface{}
for _, row := range allRows {
projected := make([]string, len(colIndices))
rec := make(map[string]interface{}, len(headers))
for i, idx := range colIndices {
projected[i] = row.display[idx]
rec[headers[i]] = row.display[idx]
}
rows = append(rows, projected)
records = append(records, rec)
}
printMarkdownTable(headers, rows)
renderTableWithTablewriter("", records, headers, globalWidth, false, nil)
return nil
},
}
+12 -12
View File
@@ -391,26 +391,26 @@ func runWorkerQueueList(cmd *cobra.Command, args []string) error {
// Display table
printer.Section("Queued Tasks")
headers := []string{"ID", "UUID", "Workflow", "Target", "Status", "Priority", "IsFile", "Created"}
var rows [][]string
columns := []string{"id", "uuid", "workflow", "target", "status", "priority", "is_file", "created"}
var records []map[string]interface{}
for _, r := range runs {
isFileStr := ""
if r.InputIsFile {
isFileStr = "yes"
}
rows = append(rows, []string{
fmt.Sprintf("%d", r.ID),
truncateUUID(r.RunUUID),
r.WorkflowName,
truncateQueueStr(r.Target, 40),
colorizeQueueStatus(r.Status),
r.RunPriority,
isFileStr,
r.CreatedAt.Format("2006-01-02 15:04"),
records = append(records, map[string]interface{}{
"id": fmt.Sprintf("%d", r.ID),
"uuid": truncateUUID(r.RunUUID),
"workflow": r.WorkflowName,
"target": truncateQueueStr(r.Target, 40),
"status": r.Status,
"priority": r.RunPriority,
"is_file": isFileStr,
"created": r.CreatedAt.Format("2006-01-02 15:04"),
})
}
printMarkdownTable(headers, rows)
renderTableWithTablewriter("", records, columns, globalWidth, false, nil)
fmt.Println()
printer.Info("Total: %d queued task(s)", len(runs))
return nil
+237 -167
View File
@@ -13,7 +13,9 @@ import (
"github.com/j3ssie/osmedeus/v5/internal/linter"
"github.com/j3ssie/osmedeus/v5/internal/parser"
"github.com/j3ssie/osmedeus/v5/internal/terminal"
"github.com/mattn/go-runewidth"
"github.com/spf13/cobra"
"golang.org/x/term"
)
// workflowCmd represents the workflow command
@@ -555,130 +557,15 @@ func stripAnsi(s string) string {
return re.ReplaceAllString(s, "")
}
// isToggleParam detects boolean/toggle parameters by:
// - Type == "bool"
// - Name patterns: enableX, enable_X, skipX, skip_X, disableX, useX, verboseX
// - Default value is bool (true/false)
func isToggleParam(p core.Param) bool {
// Check explicit type
if p.Type == "bool" {
return true
}
// Check name patterns (case-insensitive)
name := strings.ToLower(p.Name)
togglePrefixes := []string{"enable", "skip", "disable", "use", "verbose"}
for _, prefix := range togglePrefixes {
if strings.HasPrefix(name, prefix) {
return true
}
// Also check with underscore: enable_xxx, skip_xxx
if strings.HasPrefix(name, prefix+"_") {
return true
}
}
// Check if default value is boolean
if p.Default != nil {
switch p.Default.(type) {
case bool:
return true
}
// Also check string representation
defaultStr := strings.ToLower(p.DefaultString())
if defaultStr == "true" || defaultStr == "false" {
return true
}
}
return false
// displayWidth returns the visual display width of a string, ignoring ANSI codes.
// Unlike len(), this correctly handles multi-byte Unicode characters like ✔ (3 bytes, 1 display char).
func displayWidth(s string) int {
return runewidth.StringWidth(stripAnsi(s))
}
// isSpeedControlParam detects performance/speed parameters by:
// - Name contains: threads, timeout, rate, concurrency, delay, limit, workers, parallel, batch, interval, retry
// - Name ends with: depth, parallel
// - Default value matches time pattern: \d+[hms]
func isSpeedControlParam(p core.Param) bool {
name := strings.ToLower(p.Name)
speedPatterns := []string{
"threads", "timeout", "rate", "concurrency", "delay",
"limit", "workers", "parallel", "batch", "interval", "retry",
}
for _, pattern := range speedPatterns {
if strings.Contains(name, pattern) {
return true
}
}
// Check suffix patterns for depth and parallel
speedSuffixes := []string{"depth", "parallel"}
for _, suffix := range speedSuffixes {
if strings.HasSuffix(name, suffix) {
return true
}
}
// Check for time pattern in default value (e.g., 8h, 30m, 1800)
if p.Default != nil {
defaultStr := p.DefaultString()
// Match patterns like: 8h, 30m, 1800, 60s
timePatternRegex := regexp.MustCompile(`^\d+[hms]?$`)
if timePatternRegex.MatchString(defaultStr) {
// Also verify it's numeric or has time suffix
if len(defaultStr) > 0 {
lastChar := defaultStr[len(defaultStr)-1]
// If it ends with h, m, or s, it's a time value
if lastChar == 'h' || lastChar == 'm' || lastChar == 's' {
return true
}
// If purely numeric with reasonable size, could be timeout/limit
if p.Type == "int" || p.Type == "" {
// Check if numeric only
numericRegex := regexp.MustCompile(`^\d+$`)
if numericRegex.MatchString(defaultStr) {
// Large numbers (>100) are likely timeouts/limits
val := 0
_, _ = fmt.Sscanf(defaultStr, "%d", &val)
if val > 100 {
return true
}
}
}
}
}
}
return false
}
// isConfigParam detects configuration parameters by:
// - Name ends with: Config, config, Cfg, cfg
func isConfigParam(p core.Param) bool {
name := strings.ToLower(p.Name)
configSuffixes := []string{"config", "cfg"}
for _, suffix := range configSuffixes {
if strings.HasSuffix(name, suffix) {
return true
}
}
return false
}
// categorizeParams groups params into Toggle, Speed, Config, and General categories
// categorizeParams is a convenience wrapper around core.CategorizeParams
func categorizeParams(params []core.Param) (toggle, speed, config, general []core.Param) {
for _, p := range params {
if isToggleParam(p) {
toggle = append(toggle, p)
} else if isSpeedControlParam(p) {
speed = append(speed, p)
} else if isConfigParam(p) {
config = append(config, p)
} else {
general = append(general, p)
}
}
return
return core.CategorizeParams(params)
}
// printToggleParams prints toggle parameters with green highlighting
@@ -734,6 +621,26 @@ func printSpeedControlParams(params []core.Param) {
printMarkdownTable([]string{"Name", "Default", "Required"}, rows)
}
// printToggleParamsGrouped prints all toggle parameters across modules in a single table
func printToggleParamsGrouped(rows [][]string) {
if len(rows) == 0 {
return
}
fmt.Println()
fmt.Println("◐ " + terminal.Bold("Toggle Parameters:"))
printMarkdownTable([]string{"Module", "Name", "Default", "Required"}, rows)
}
// printSpeedControlParamsGrouped prints all speed control parameters across modules in a single table
func printSpeedControlParamsGrouped(rows [][]string) {
if len(rows) == 0 {
return
}
fmt.Println()
fmt.Println("◎ " + terminal.Bold("Speed Control Parameters:"))
printMarkdownTable([]string{"Module", "Name", "Default", "Required"}, rows)
}
// printConfigParams prints config parameters with magenta highlighting
func printConfigParams(params []core.Param) {
if len(params) == 0 {
@@ -783,56 +690,79 @@ func printGeneralParams(params []core.Param) {
}
rows = append(rows, []string{terminal.Cyan(p.Name), coloredDefault, required})
}
printMarkdownTable([]string{"Name", "Default", "Required"}, rows)
// Use width-aware table to wrap long default values
// Reserve: ~42 for Name col, ~10 for Required col, ~8 for separators/padding
maxDefaultWidth := 60 // sensible default
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 80 {
maxDefaultWidth = w - 60
if maxDefaultWidth < 30 {
maxDefaultWidth = 30
}
}
printMarkdownTableWithWidth([]string{"Name", "Default", "Required"}, rows, maxDefaultWidth)
}
// printMarkdownTable prints an aligned markdown table (supports colored cells)
func printMarkdownTable(headers []string, rows [][]string) {
// Calculate column widths (using display length, not byte length)
// printMarkdownTable prints a box-drawing table with left-aligned columns (supports colored cells).
// The optional align parameter is accepted for backward compatibility but ignored.
func printMarkdownTable(headers []string, rows [][]string, align ...string) {
// Calculate column widths (using display width, not byte length)
widths := make([]int, len(headers))
for i, h := range headers {
widths[i] = len(stripAnsi(h))
widths[i] = displayWidth(h)
}
for _, row := range rows {
for i, cell := range row {
displayLen := len(stripAnsi(cell))
if i < len(widths) && displayLen > widths[i] {
widths[i] = displayLen
dw := displayWidth(cell)
if i < len(widths) && dw > widths[i] {
widths[i] = dw
}
}
}
// Print header
fmt.Print("|")
for i, h := range headers {
fmt.Printf(" %-*s |", widths[i], h)
printAlignedCell(h, widths[i])
if i < len(headers)-1 {
fmt.Print("│")
}
}
fmt.Println()
// Print separator
fmt.Print("|")
for _, w := range widths {
fmt.Printf("-%s-|", strings.Repeat("-", w))
// Print separator with box-drawing characters
for i, w := range widths {
fmt.Print(strings.Repeat("─", w+2))
if i < len(widths)-1 {
fmt.Print("┼")
}
}
fmt.Println()
// Print rows (with ANSI-aware padding)
// Print rows
for _, row := range rows {
fmt.Print("|")
for i := range headers {
cell := ""
if i < len(row) {
cell = row[i]
}
// Calculate padding needed (display width vs actual string length)
displayLen := len(stripAnsi(cell))
padding := widths[i] - displayLen
fmt.Printf(" %s%s |", cell, strings.Repeat(" ", padding))
printAlignedCell(cell, widths[i])
if i < len(headers)-1 {
fmt.Print("│")
}
}
fmt.Println()
}
}
// printAlignedCell prints a single left-aligned table cell within colWidth.
func printAlignedCell(cell string, colWidth int) {
dw := displayWidth(cell)
pad := colWidth - dw
if pad < 0 {
pad = 0
}
fmt.Printf(" %s%s ", cell, strings.Repeat(" ", pad))
}
// wrapText wraps text to maxWidth characters, preserving existing newlines
func wrapText(text string, maxWidth int) []string {
if maxWidth <= 0 {
@@ -892,7 +822,7 @@ func wrapText(text string, maxWidth int) []string {
return result
}
// printMarkdownTableWithWidth prints a table with column width wrapping
// printMarkdownTableWithWidth prints a box-drawing table with column width wrapping
func printMarkdownTableWithWidth(headers []string, rows [][]string, maxWidth int) {
// First pass: wrap all cells and calculate column widths
type wrappedRow struct {
@@ -903,9 +833,9 @@ func printMarkdownTableWithWidth(headers []string, rows [][]string, maxWidth int
var wrappedRows []wrappedRow
widths := make([]int, len(headers))
// Initialize widths with header lengths
// Initialize widths with header display widths
for i, h := range headers {
widths[i] = len(stripAnsi(h))
widths[i] = displayWidth(h)
}
// Wrap each cell and track widths
@@ -923,9 +853,9 @@ func printMarkdownTableWithWidth(headers []string, rows [][]string, maxWidth int
}
// Track max width for this column
for _, line := range wrapped {
lineWidth := len(stripAnsi(line))
if lineWidth > widths[i] {
widths[i] = lineWidth
lw := displayWidth(line)
if lw > widths[i] {
widths[i] = lw
}
}
}
@@ -933,32 +863,35 @@ func printMarkdownTableWithWidth(headers []string, rows [][]string, maxWidth int
}
// Print header
fmt.Print("|")
for i, h := range headers {
fmt.Printf(" %-*s |", widths[i], h)
printAlignedCell(h, widths[i])
if i < len(headers)-1 {
fmt.Print("│")
}
}
fmt.Println()
// Print separator
fmt.Print("|")
for _, w := range widths {
fmt.Printf("-%s-|", strings.Repeat("-", w))
// Print separator with box-drawing characters
for i, w := range widths {
fmt.Print(strings.Repeat("─", w+2))
if i < len(widths)-1 {
fmt.Print("┼")
}
}
fmt.Println()
// Print rows (with multi-line support)
// Print rows with multi-line support
for _, wr := range wrappedRows {
for lineIdx := 0; lineIdx < wr.maxHeight; lineIdx++ {
fmt.Print("|")
for colIdx := range headers {
cell := ""
if lineIdx < len(wr.cells[colIdx]) {
cell = wr.cells[colIdx][lineIdx]
}
// Calculate padding needed (display width vs actual string length)
displayLen := len(stripAnsi(cell))
padding := widths[colIdx] - displayLen
fmt.Printf(" %s%s |", cell, strings.Repeat(" ", padding))
printAlignedCell(cell, widths[colIdx])
if colIdx < len(headers)-1 {
fmt.Print("│")
}
}
fmt.Println()
}
@@ -1024,23 +957,87 @@ var workflowShowCmd = &cobra.Command{
printer.KeyValue("Description", workflow.Description)
printer.KeyValue("File", terminal.Gray(workflow.FilePath))
// For flows, show aggregated module param counts before Usage
if workflow.IsFlow() && len(workflow.Modules) > 0 {
var totalSpeed, totalToggle, totalCfg, totalGeneral int
for _, m := range workflow.Modules {
if m.Path == "" {
continue
}
mod, modErr := loader.LoadWorkflow(m.Path)
if modErr != nil {
continue
}
mToggle, mSpeed, mCfg, mGeneral := core.CategorizeParams(mod.Params)
totalSpeed += len(mSpeed)
totalToggle += len(mToggle)
totalCfg += len(mCfg)
totalGeneral += len(mGeneral)
}
var aggParts []string
if totalSpeed > 0 {
aggParts = append(aggParts, fmt.Sprintf("%s speed control", terminal.Magenta(fmt.Sprintf("%d", totalSpeed))))
}
if totalToggle > 0 {
aggParts = append(aggParts, fmt.Sprintf("%s toggle", terminal.Magenta(fmt.Sprintf("%d", totalToggle))))
}
if totalCfg > 0 {
aggParts = append(aggParts, fmt.Sprintf("%s config", terminal.Magenta(fmt.Sprintf("%d", totalCfg))))
}
if totalGeneral > 0 {
aggParts = append(aggParts, fmt.Sprintf("%s general", terminal.Magenta(fmt.Sprintf("%d", totalGeneral))))
}
if len(aggParts) > 0 {
printer.KeyValue("Params", strings.Join(aggParts, ", "))
}
}
// Show help info
if workflow.Help != nil {
if workflow.Help.Usage != "" {
printer.KeyValue("Usage", terminal.Cyan(workflow.Help.Usage))
if strings.Contains(workflow.Help.Usage, "\n") {
fmt.Println(" " + terminal.Gray("Usage") + ":")
for _, line := range strings.Split(strings.TrimRight(workflow.Help.Usage, "\n "), "\n") {
fmt.Println(" " + terminal.Cyan(line))
}
} else {
printer.KeyValue("Usage", terminal.Cyan(workflow.Help.Usage))
}
}
if len(workflow.Help.ExampleTargets) > 0 {
printer.KeyValue("Example Targets", terminal.Yellow(strings.Join(workflow.Help.ExampleTargets, ", ")))
}
}
// Show parameters (categorized)
// Show parameters
if len(workflow.Params) > 0 {
toggle, speed, config, general := categorizeParams(workflow.Params)
printToggleParams(toggle)
printSpeedControlParams(speed)
printConfigParams(config)
printGeneralParams(general)
toggle, speed, cfg, general := categorizeParams(workflow.Params)
// Always show summary counts
var parts []string
if len(speed) > 0 {
parts = append(parts, fmt.Sprintf("%s speed control", terminal.Magenta(fmt.Sprintf("%d", len(speed)))))
}
if len(toggle) > 0 {
parts = append(parts, fmt.Sprintf("%s toggle", terminal.Magenta(fmt.Sprintf("%d", len(toggle)))))
}
if len(cfg) > 0 {
parts = append(parts, fmt.Sprintf("%s config", terminal.Magenta(fmt.Sprintf("%d", len(cfg)))))
}
if len(general) > 0 {
parts = append(parts, fmt.Sprintf("%s general", terminal.Magenta(fmt.Sprintf("%d", len(general)))))
}
if len(parts) > 0 {
printer.KeyValue("Params", strings.Join(parts, ", "))
}
// Show full categorized tables only in verbose mode
if showVerbose {
printToggleParams(toggle)
printSpeedControlParams(speed)
printConfigParams(cfg)
printGeneralParams(general)
}
}
// Show steps (for modules)
@@ -1068,6 +1065,79 @@ var workflowShowCmd = &cobra.Command{
rows = append(rows, []string{fmt.Sprintf("%d", i+1), m.Name, m.Path, deps})
}
printMarkdownTable([]string{"#", "Name", "Path", "Depends On"}, rows)
// Load each module and show params
fmt.Println()
fmt.Println("◆ " + terminal.Bold("Module Parameters:"))
if showVerbose {
// Verbose: collect all params across modules, then render grouped tables
var allSpeedRows [][]string
var allToggleRows [][]string
for _, m := range workflow.Modules {
if m.Path == "" {
continue
}
mod, err := loader.LoadWorkflow(m.Path)
if err != nil {
continue
}
mToggle, mSpeed, _, _ := core.CategorizeParams(mod.Params)
for _, p := range mSpeed {
required := terminal.Gray("no")
if p.Required {
required = terminal.Green("yes")
}
defaultVal := p.DefaultString()
if defaultVal == "" {
defaultVal = "-"
}
allSpeedRows = append(allSpeedRows, []string{terminal.HiBlue(m.Name), terminal.HiBlue(p.Name), terminal.HiBlue(defaultVal), required})
}
for _, p := range mToggle {
required := terminal.Gray("no")
if p.Required {
required = terminal.Green("yes")
}
defaultVal := p.DefaultString()
if defaultVal == "" {
defaultVal = "-"
}
coloredDefault := defaultVal
if strings.ToLower(defaultVal) == "true" {
coloredDefault = terminal.Green(defaultVal)
} else if strings.ToLower(defaultVal) == "false" {
coloredDefault = terminal.Gray(defaultVal)
}
allToggleRows = append(allToggleRows, []string{terminal.HiCyan(m.Name), terminal.HiCyan(p.Name), coloredDefault, required})
}
}
printSpeedControlParamsGrouped(allSpeedRows)
printToggleParamsGrouped(allToggleRows)
} else {
// Summary: show counts per module
for _, m := range workflow.Modules {
if m.Path == "" {
continue
}
mod, err := loader.LoadWorkflow(m.Path)
if err != nil {
continue
}
mToggle, mSpeed, _, _ := core.CategorizeParams(mod.Params)
if len(mToggle)+len(mSpeed) == 0 {
continue
}
var mParts []string
if len(mSpeed) > 0 {
mParts = append(mParts, fmt.Sprintf("%s speed control", terminal.Magenta(fmt.Sprintf("%d", len(mSpeed)))))
}
if len(mToggle) > 0 {
mParts = append(mParts, fmt.Sprintf("%s toggle", terminal.Magenta(fmt.Sprintf("%d", len(mToggle)))))
}
printer.KeyValue(" "+m.Name, strings.Join(mParts, ", "))
}
}
}
// Show triggers
@@ -1192,7 +1262,7 @@ var workflowShowCmd = &cobra.Command{
}
fmt.Println()
fmt.Println()
fmt.Printf(" %s%s\n", terminal.BoldCyan(terminal.SymbolLightning), terminal.Gray("Tip: Use --verbose to show all variables with descriptions and default values or --yaml to see raw workflow file"))
fmt.Printf("%s %s\n", terminal.BoldCyan(terminal.SymbolLightning), terminal.Gray("Tip: Use --verbose to show all params, variables with descriptions and default values or --yaml to see raw workflow file"))
}
fmt.Println()