Repository: dagu-org/dagu

Issue:
[Feat] Extend `jq` executor for raw-output option
We can extend the jq executor for raw output option.
```yaml
- executor:
     type: jq
     config:
       raw: true
     command: .foo
     script: |
        {"foo": "bar"}
```
Expect: `bar`
Current: `"bar"`

file: https://github.com/dagu-org/dagu/blob/main/internal/runtime/builtin/jq/jq.go

Hints from issue comments:
@yottahmd: Fixed in #1392
Thanks a lot!

Solution patch:
diff --git a/internal/runtime/builtin/jq/jq.go b/internal/runtime/builtin/jq/jq.go
index 0044d34e..3718d0eb 100644
--- a/internal/runtime/builtin/jq/jq.go
+++ b/internal/runtime/builtin/jq/jq.go
@@ -6,7 +6,6 @@ import (
 	"fmt"
 	"io"
 	"os"
-	"strings"
 
 	"github.com/dagu-org/dagu/internal/core"
 	"github.com/dagu-org/dagu/internal/runtime/executor"
@@ -20,7 +19,7 @@ type jq struct {
 	stdout io.Writer
 	stderr io.Writer
 	query  string
-	input  map[string]any
+	input  any
 	cfg    *jqConfig
 }
 
@@ -37,7 +36,7 @@ func newJQ(_ context.Context, step core.Step) (executor.Executor, error) {
 			return nil, err
 		}
 	}
-	input := map[string]any{}
+	var input any
 	if err := json.Unmarshal([]byte(step.Script), &input); err != nil {
 		return nil, err
 	}
@@ -77,18 +76,43 @@ func (e *jq) Run(_ context.Context) error {
 			continue
 		}
 		if e.cfg.Raw {
-			// In raw mode, handle strings specially to preserve tabs, newlines, etc
+			// In raw mode, output values without JSON encoding
 			switch v := v.(type) {
 			case string:
+				// For strings, print directly without quotes
+				_, _ = fmt.Fprintln(e.stdout, v)
+			case nil:
+				// For null, print nothing or empty line
+				_, _ = fmt.Fprintln(e.stdout)
+			case bool:
+				// For booleans, print as lowercase string
+				if v {
+					_, _ = fmt.Fprintln(e.stdout, "true")
+				} else {
+					_, _ = fmt.Fprintln(e.stdout, "false")
+				}
+			case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
+				// For numbers, print without quotes
 				_, _ = fmt.Fprintln(e.stdout, v)
 			default:
-				// For non-strings, convert to string representation
+				// For arrays/objects or other types, marshal to JSON
 				val, err := json.Marshal(v)
 				if err != nil {
 					_, _ = fmt.Fprintf(e.stderr, "failed to marshal jq output: %v", err)
 					continue
 				}
-				_, _ = fmt.Fprintln(e.stdout, strings.Trim(string(val), `"`))
+				// If the JSON is a quoted string, unquote it
+				output := string(val)
+				if len(output) >= 2 && output[0] == '"' && output[len(output)-1] == '"' {
+					var unquoted string
+					if err := json.Unmarshal(val, &unquoted); err == nil {
+						_, _ = fmt.Fprintln(e.stdout, unquoted)
+					} else {
+						_, _ = fmt.Fprintln(e.stdout, output)
+					}
+				} else {
+					_, _ = fmt.Fprintln(e.stdout, output)
+				}
 			}
 		} else {
 			// In non-raw mode, use JSON formatting



Test patch:
diff --git a/internal/integration/jq_test.go b/internal/integration/jq_test.go
index e266e19f..1282cf87 100644
--- a/internal/integration/jq_test.go
+++ b/internal/integration/jq_test.go
@@ -134,4 +134,192 @@ func TestJQExecutor(t *testing.T) {
 			"RESULT": "1\t100\n2\t200\n3\t300",
 		})
 	})
+
+	t.Run("SingleStringWithRawTrue", func(t *testing.T) {
+		t.Parallel()
+
+		th := test.Setup(t)
+		dag := th.DAG(t, `steps:
+  - name: extract-single-string-raw
+    executor: 
+      type: jq
+      config:
+        raw: true
+    script: |
+      {"foo": "bar"}
+    command: .foo
+    output: RESULT
+`)
+
+		agent := dag.Agent()
+
+		agent.RunSuccess(t)
+
+		dag.AssertLatestStatus(t, core.Succeeded)
+		dag.AssertOutputs(t, map[string]any{
+			"RESULT": "bar",
+		})
+	})
+
+	t.Run("SingleStringWithRawFalse", func(t *testing.T) {
+		t.Parallel()
+
+		th := test.Setup(t)
+		dag := th.DAG(t, `steps:
+  - name: extract-single-string-json
+    executor: 
+      type: jq
+      config:
+        raw: false
+    script: |
+      {"foo": "bar"}
+    command: .foo
+    output: RESULT
+`)
+
+		agent := dag.Agent()
+
+		agent.RunSuccess(t)
+
+		dag.AssertLatestStatus(t, core.Succeeded)
+		dag.AssertOutputs(t, map[string]any{
+			"RESULT": `"bar"`,
+		})
+	})
+
+	t.Run("SingleNumberWithRawTrue", func(t *testing.T) {
+		t.Parallel()
+
+		th := test.Setup(t)
+		dag := th.DAG(t, `steps:
+  - name: extract-single-number-raw
+    executor: 
+      type: jq
+      config:
+        raw: true
+    script: |
+      {"value": 42}
+    command: .value
+    output: RESULT
+`)
+
+		agent := dag.Agent()
+
+		agent.RunSuccess(t)
+
+		dag.AssertLatestStatus(t, core.Succeeded)
+		dag.AssertOutputs(t, map[string]any{
+			"RESULT": "42",
+		})
+	})
+
+	t.Run("SingleBooleanWithRawTrue", func(t *testing.T) {
+		t.Parallel()
+
+		th := test.Setup(t)
+		dag := th.DAG(t, `steps:
+  - name: extract-single-boolean-raw
+    executor: 
+      type: jq
+      config:
+        raw: true
+    script: |
+      {"enabled": true, "disabled": false}
+    command: .enabled
+    output: ENABLED
+`)
+
+		agent := dag.Agent()
+
+		agent.RunSuccess(t)
+
+		dag.AssertLatestStatus(t, core.Succeeded)
+		dag.AssertOutputs(t, map[string]any{
+			"ENABLED": "true",
+		})
+	})
+
+	t.Run("NullValueWithRawTrue", func(t *testing.T) {
+		t.Parallel()
+
+		th := test.Setup(t)
+		dag := th.DAG(t, `steps:
+  - name: extract-null-raw
+    executor: 
+      type: jq
+      config:
+        raw: true
+    script: |
+      {"value": null}
+    command: .value
+    output: RESULT
+`)
+
+		agent := dag.Agent()
+
+		agent.RunSuccess(t)
+
+		dag.AssertLatestStatus(t, core.Succeeded)
+		// Null values output empty string, but the output variable still exists
+		// So we check that it contains an empty value
+		dag.AssertOutputs(t, map[string]any{
+			"RESULT": test.Contains("RESULT="),
+		})
+	})
+
+	t.Run("ObjectWithRawTrue", func(t *testing.T) {
+		t.Parallel()
+
+		th := test.Setup(t)
+		dag := th.DAG(t, `steps:
+  - name: extract-object-raw
+    executor: 
+      type: jq
+      config:
+        raw: true
+    script: |
+      {"user": {"name": "John", "age": 30}}
+    command: .user
+    output: RESULT
+`)
+
+		agent := dag.Agent()
+
+		agent.RunSuccess(t)
+
+		dag.AssertLatestStatus(t, core.Succeeded)
+		// In raw mode, object is output as compact JSON (key order not guaranteed)
+		dag.AssertOutputs(t, map[string]any{
+			"RESULT": []test.Contains{
+				test.Contains(`"name":"John"`),
+				test.Contains(`"age":30`),
+			},
+		})
+	})
+
+	t.Run("StringWithSpecialCharsWithRawTrue", func(t *testing.T) {
+		t.Parallel()
+
+		th := test.Setup(t)
+		dag := th.DAG(t, `steps:
+  - name: extract-special-chars-raw
+    executor: 
+      type: jq
+      config:
+        raw: true
+    script: |
+      {"message": "hello\nworld\ttab"}
+    command: .message
+    output: RESULT
+`)
+
+		agent := dag.Agent()
+
+		agent.RunSuccess(t)
+
+		dag.AssertLatestStatus(t, core.Succeeded)
+		dag.AssertOutputs(t, map[string]any{
+			"RESULT": "hello\nworld\ttab",
+		})
+	})
 }
diff --git a/internal/runtime/builtin/jq/jq_test.go b/internal/runtime/builtin/jq/jq_test.go
new file mode 100644
index 00000000..4eed9028
--- /dev/null
+++ b/internal/runtime/builtin/jq/jq_test.go
@@ -0,0 +1,416 @@
+package jq
+
+import (
+	"bytes"
+	"context"
+	"strings"
+	"testing"
+
+	"github.com/dagu-org/dagu/internal/core"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestJQExecutor_RawOutput(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name           string
+		query          string
+		script         string
+		raw            bool
+		expectedOutput string
+		compareJSON    bool
+	}{
+		{
+			name:           "SingleStringWithRawTrue",
+			query:          ".foo",
+			script:         `{"foo": "bar"}`,
+			raw:            true,
+			expectedOutput: "bar\n",
+		},
+		{
+			name:           "SingleStringWithRawFalse",
+			query:          ".foo",
+			script:         `{"foo": "bar"}`,
+			raw:            false,
+			expectedOutput: "\"bar\"\n",
+		},
+		{
+			name:           "SingleNumberWithRawTrue",
+			query:          ".number",
+			script:         `{"number": 42}`,
+			raw:            true,
+			expectedOutput: "42\n",
+		},
+		{
+			name:           "SingleNumberWithRawFalse",
+			query:          ".number",
+			script:         `{"number": 42}`,
+			raw:            false,
+			expectedOutput: "42\n",
+		},
+		{
+			name:           "SingleBooleanTrueWithRawTrue",
+			query:          ".flag",
+			script:         `{"flag": true}`,
+			raw:            true,
+			expectedOutput: "true\n",
+		},
+		{
+			name:           "SingleBooleanFalseWithRawTrue",
+			query:          ".flag",
+			script:         `{"flag": false}`,
+			raw:            true,
+			expectedOutput: "false\n",
+		},
+		{
+			name:           "NullValueWithRawTrue",
+			query:          ".nullValue",
+			script:         `{"nullValue": null}`,
+			raw:            true,
+			expectedOutput: "\n",
+		},
+		{
+			name:           "ObjectWithRawTrue",
+			query:          ".",
+			script:         `{"foo": "bar", "baz": 123}`,
+			raw:            true,
+			expectedOutput: `{"foo":"bar","baz":123}`,
+			compareJSON:    true,
+		},
+		{
+			name:           "ObjectWithRawFalse",
+			query:          ".",
+			script:         `{"foo": "bar", "baz": 123}`,
+			raw:            false,
+			expectedOutput: `{"foo":"bar","baz":123}`,
+			compareJSON:    true,
+		},
+		{
+			name:           "ArrayFromObjectWithRawTrue",
+			query:          ".items",
+			script:         `{"items": [1, 2, 3]}`,
+			raw:            true,
+			expectedOutput: "[1,2,3]\n",
+		},
+		{
+			name:           "ArrayFromObjectWithRawFalse",
+			query:          ".items",
+			script:         `{"items": [1, 2, 3]}`,
+			raw:            false,
+			expectedOutput: "[\n    1,\n    2,\n    3\n]\n",
+		},
+		{
+			name:           "TopLevelArrayWithRawTrue",
+			query:          ".",
+			script:         `["foo", "bar"]`,
+			raw:            true,
+			expectedOutput: "[\"foo\",\"bar\"]\n",
+		},
+		{
+			name:           "TopLevelArrayWithRawFalse",
+			query:          ".",
+			script:         `["foo", "bar"]`,
+			raw:            false,
+			expectedOutput: "[\n    \"foo\",\n    \"bar\"\n]\n",
+		},
+		{
+			name:           "TopLevelStringWithRawTrue",
+			query:          ".",
+			script:         `"bar"`,
+			raw:            true,
+			expectedOutput: "bar\n",
+		},
+		{
+			name:           "TopLevelStringWithRawFalse",
+			query:          ".",
+			script:         `"bar"`,
+			raw:            false,
+			expectedOutput: "\"bar\"\n",
+		},
+		{
+			name:           "StringWithSpecialCharsWithRawTrue",
+			query:          ".message",
+			script:         `{"message": "hello\nworld"}`,
+			raw:            true,
+			expectedOutput: "hello\nworld\n",
+		},
+		{
+			name:           "StringWithTabsWithRawTrue",
+			query:          ".data",
+			script:         `{"data": "a\tb\tc"}`,
+			raw:            true,
+			expectedOutput: "a\tb\tc\n",
+		},
+		{
+			name:           "FloatNumberWithRawTrue",
+			query:          ".pi",
+			script:         `{"pi": 3.14159}`,
+			raw:            true,
+			expectedOutput: "3.14159\n",
+		},
+		{
+			name:           "NegativeNumberWithRawTrue",
+			query:          ".temp",
+			script:         `{"temp": -10}`,
+			raw:            true,
+			expectedOutput: "-10\n",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			var stdout, stderr bytes.Buffer
+
+			step := core.Step{
+				CmdWithArgs: tt.query,
+				Script:      tt.script,
+				ExecutorConfig: core.ExecutorConfig{
+					Type: "jq",
+					Config: map[string]any{
+						"raw": tt.raw,
+					},
+				},
+			}
+
+			ctx := context.Background()
+			executor, err := newJQ(ctx, step)
+			require.NoError(t, err)
+
+			executor.SetStdout(&stdout)
+			executor.SetStderr(&stderr)
+
+			err = executor.Run(ctx)
+			require.NoError(t, err)
+
+			output := stdout.String()
+			if tt.compareJSON {
+				require.JSONEq(t, tt.expectedOutput, strings.TrimSpace(output))
+				return
+			}
+			assert.Equal(t, tt.expectedOutput, output)
+		})
+	}
+}
+
+func TestJQExecutor_MultipleOutputs(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name           string
+		query          string
+		script         string
+		raw            bool
+		expectedOutput string
+	}{
+		{
+			name:           "MultipleStringsWithRawTrue",
+			query:          ".items[]",
+			script:         `{"items": ["foo", "bar", "baz"]}`,
+			raw:            true,
+			expectedOutput: "foo\nbar\nbaz\n",
+		},
+		{
+			name:           "MultipleStringsWithRawFalse",
+			query:          ".items[]",
+			script:         `{"items": ["foo", "bar", "baz"]}`,
+			raw:            false,
+			expectedOutput: "\"foo\"\n\"bar\"\n\"baz\"\n",
+		},
+		{
+			name:           "MultipleNumbersWithRawTrue",
+			query:          ".numbers[]",
+			script:         `{"numbers": [1, 2, 3]}`,
+			raw:            true,
+			expectedOutput: "1\n2\n3\n",
+		},
+		{
+			name:           "MixedTypesWithRawTrue",
+			query:          ".values[]",
+			script:         `{"values": ["text", 42, true, null]}`,
+			raw:            true,
+			expectedOutput: "text\n42\ntrue\n\n",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			var stdout, stderr bytes.Buffer
+
+			step := core.Step{
+				CmdWithArgs: tt.query,
+				Script:      tt.script,
+				ExecutorConfig: core.ExecutorConfig{
+					Type: "jq",
+					Config: map[string]any{
+						"raw": tt.raw,
+					},
+				},
+			}
+
+			ctx := context.Background()
+			executor, err := newJQ(ctx, step)
+			require.NoError(t, err)
+
+			executor.SetStdout(&stdout)
+			executor.SetStderr(&stderr)
+
+			err = executor.Run(ctx)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.expectedOutput, stdout.String())
+		})
+	}
+}
+
+func TestJQExecutor_InvalidQuery(t *testing.T) {
+	t.Parallel()
+
+	var stdout, stderr bytes.Buffer
+
+	step := core.Step{
+		CmdWithArgs: "invalid jq syntax {",
+		Script:      `{"foo": "bar"}`,
+		ExecutorConfig: core.ExecutorConfig{
+			Type: "jq",
+			Config: map[string]any{
+				"raw": true,
+			},
+		},
+	}
+
+	ctx := context.Background()
+	executor, err := newJQ(ctx, step)
+	require.NoError(t, err)
+
+	executor.SetStdout(&stdout)
+	executor.SetStderr(&stderr)
+
+	err = executor.Run(ctx)
+	assert.Error(t, err)
+}
+
+func TestJQExecutor_InvalidJSON(t *testing.T) {
+	t.Parallel()
+
+	step := core.Step{
+		CmdWithArgs: ".foo",
+		Script:      `invalid json`,
+		ExecutorConfig: core.ExecutorConfig{
+			Type: "jq",
+			Config: map[string]any{
+				"raw": true,
+			},
+		},
+	}
+
+	ctx := context.Background()
+	_, err := newJQ(ctx, step)
+	assert.Error(t, err)
+}
+
+func TestJQExecutor_NoConfig(t *testing.T) {
+	t.Parallel()
+
+	var stdout bytes.Buffer
+
+	step := core.Step{
+		CmdWithArgs: ".foo",
+		Script:      `{"foo": "bar"}`,
+		ExecutorConfig: core.ExecutorConfig{
+			Type:   "jq",
+			Config: nil, // No config, should default to raw: false
+		},
+	}
+
+	ctx := context.Background()
+	executor, err := newJQ(ctx, step)
+	require.NoError(t, err)
+
+	executor.SetStdout(&stdout)
+
+	err = executor.Run(ctx)
+	require.NoError(t, err)
+
+	// Without raw config, should output JSON formatted
+	assert.Contains(t, stdout.String(), "\"bar\"")
+}
+
+func TestJQExecutor_Kill(t *testing.T) {
+	t.Parallel()
+
+	step := core.Step{
+		CmdWithArgs: ".foo",
+		Script:      `{"foo": "bar"}`,
+		ExecutorConfig: core.ExecutorConfig{
+			Type: "jq",
+		},
+	}
+
+	ctx := context.Background()
+	executor, err := newJQ(ctx, step)
+	require.NoError(t, err)
+
+	// Kill should return nil (jq executor doesn't need cleanup)
+	err = executor.Kill(nil)
+	assert.NoError(t, err)
+}
+
+func TestDecodeJqConfig(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		config   map[string]any
+		expected jqConfig
+	}{
+		{
+			name: "RawTrue",
+			config: map[string]any{
+				"raw": true,
+			},
+			expected: jqConfig{
+				Raw: true,
+			},
+		},
+		{
+			name: "RawFalse",
+			config: map[string]any{
+				"raw": false,
+			},
+			expected: jqConfig{
+				Raw: false,
+			},
+		},
+		{
+			name: "RawAsString",
+			config: map[string]any{
+				"raw": "true",
+			},
+			expected: jqConfig{
+				Raw: true,
+			},
+		},
+		{
+			name:     "EmptyConfig",
+			config:   map[string]any{},
+			expected: jqConfig{},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			var cfg jqConfig
+			err := decodeJqConfig(tt.config, &cfg)
+			require.NoError(t, err)
+			assert.Equal(t, tt.expected, cfg)
+		})
+	}
+}