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
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ A scriptable CLI for [Jules](https://jules.google), written in Crystal.
- **Multi-account** — aliases via `cjules accounts use`, or one-shot with `--account`.
- **Pick** — `cjules pick` (uses `fzf` if available) with `--action show|watch|pr|delete`.
- **Retry** — `cjules retry <id>` re-runs a session by cloning its prompt, repo, branch, and flags; `--with-failure-reason` carries the original failure message into the new prompt.
- **Templates** — drop reusable prompts into `~/.config/cjules/templates/` and reference them via `--template <name>` on `new` or `retry`.
- **Templates** — drop reusable prompts into `~/.config/cjules/templates/` and reference them via `--template <name>` on `new` or `retry`. Templates support dynamic variables: `{{.File "path"}}`, `{{.GitDiff}}`, and `{{.Var "name"}}` for powerful prompt generation.

> **Heads up:** the Jules API is currently labelled `v1alpha`. Schema and
> behaviour can change without notice. Pin a release of cjules in scripts you
Expand Down Expand Up @@ -174,6 +174,11 @@ cjules ls --state FAILED --limit 1 -f jsonl | jq -r .id | xargs cjules retry --w
Drop `*.md` or `*.txt` files into `~/.config/cjules/templates/` and reference
them by filename. Templates are looked up there by short name.

Templates now support **dynamic variables** for powerful prompt generation:
- `{{.File "path"}}` — Insert file contents
- `{{.GitDiff}}` — Insert current git diff
- `{{.Var "name"}}` — Insert user-defined variables passed via `--var`

```sh
# See what's available and where they live
cjules templates ls
Expand All @@ -184,10 +189,32 @@ cjules templates show bugfix # print body
cjules new --template bugfix
cjules new --template bugfix --auto-pr --branch hotfix

# Use templates with variables
cjules new --template refactor --var file=parser.cr --var priority=high

# Or as the prompt for a retry
cjules retry <session-id> --template bugfix
```

**Example template** (`~/.config/cjules/templates/refactor.md`):
```markdown
Please refactor the following code:

[Code]
{{.File "src/parser.cr"}}

[Notes]
{{.Var "note"}}

[Current Changes]
{{.GitDiff}}
```

**Usage:**
```sh
cjules new --template refactor --var note="Improve error handling"
```

### Bulk cleanup with `prune`

`prune` is dry-run by default — review the matches, then re-run with `-y` to actually delete.
Expand Down
97 changes: 97 additions & 0 deletions spec/cjules_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ require "../src/cjules/output/colors"
require "../src/cjules/output/table"
require "../src/cjules/config"
require "../src/cjules/commands/new"
require "../src/cjules/template_renderer"
require "file_utils"

describe Cjules::Util::ID do
it "strips sessions/ prefix" do
Expand Down Expand Up @@ -637,3 +639,98 @@ describe Cjules::Config do
end
end
end
describe Cjules::TemplateRenderer do
describe ".parse_vars" do
it "parses key=value pairs" do
vars = Cjules::TemplateRenderer.parse_vars(["foo=bar", "baz=qux"])
vars.should eq({"foo" => "bar", "baz" => "qux"})
end

it "handles values with = signs" do
vars = Cjules::TemplateRenderer.parse_vars(["url=http://example.com?a=1"])
vars.should eq({"url" => "http://example.com?a=1"})
end

it "handles empty values" do
vars = Cjules::TemplateRenderer.parse_vars(["empty="])
vars.should eq({"empty" => ""})
end

it "warns and skips malformed arguments" do
vars = Cjules::TemplateRenderer.parse_vars(["good=value", "bad_no_equals", "another=ok"])
vars.should eq({"good" => "value", "another" => "ok"})
end
end

describe ".render" do
it "returns unchanged template when no directives present" do
template = "Hello world"
Cjules::TemplateRenderer.render(template).should eq("Hello world")
end

it "renders {{.Var}} directives with provided variables" do
template = "Hello {{.Var \"name\"}}, you are {{.Var \"age\"}} years old"
vars = {"name" => "Alice", "age" => "30"}
result = Cjules::TemplateRenderer.render(template, vars)
result.should eq("Hello Alice, you are 30 years old")
end

it "renders {{.Var}} with single quotes" do
template = "Hello {{.Var 'name'}}"
vars = {"name" => "Bob"}
result = Cjules::TemplateRenderer.render(template, vars)
result.should eq("Hello Bob")
end

it "replaces undefined variables with placeholder" do
template = "Value: {{.Var \"missing\"}}"
result = Cjules::TemplateRenderer.render(template)
result.should eq("Value: [undefined variable: missing]")
end

it "renders {{.File}} directives with existing files" do
with_isolated_home do |tmp|
test_file = File.join(tmp, "test.txt")
File.write(test_file, "File contents here")

template = "Content: {{.File \"#{test_file}\"}}"
result = Cjules::TemplateRenderer.render(template)
result.should eq("Content: File contents here")
end
end

it "handles missing files gracefully" do
template = "Content: {{.File \"/nonexistent/file.txt\"}}"
result = Cjules::TemplateRenderer.render(template)
result.should eq("Content: [file not found: /nonexistent/file.txt]")
end

it "renders {{.GitDiff}} when git is available and has changes" do
# This test is environment-dependent, so we'll just check the directive is processed
template = "Changes:\n{{.GitDiff}}"
result = Cjules::TemplateRenderer.render(template)
# Should either contain diff output or a placeholder message
result.should start_with("Changes:\n")
result.should_not contain("{{.GitDiff}}")
end

it "handles multiple directives in one template" do
with_isolated_home do |tmp|
test_file = File.join(tmp, "data.txt")
File.write(test_file, "DATA")

template = "Name: {{.Var \"name\"}}\nFile: {{.File \"#{test_file}\"}}\nAge: {{.Var \"age\"}}"
vars = {"name" => "Charlie", "age" => "25"}
result = Cjules::TemplateRenderer.render(template, vars)
result.should eq("Name: Charlie\nFile: DATA\nAge: 25")
end
end

it "preserves template content around directives" do
template = "Before {{.Var \"x\"}} middle {{.Var \"y\"}} after"
vars = {"x" => "X", "y" => "Y"}
result = Cjules::TemplateRenderer.render(template, vars)
result.should eq("Before X middle Y after")
end
end
end
8 changes: 7 additions & 1 deletion src/cjules/commands/new.cr
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ module Cjules
output = "text"
reconcile_on_error = true
positional = [] of String
var_args = [] of String

parser = OptionParser.new do |p|
p.banner = "Usage: cjules new [PROMPT|-] [options]"
Expand All @@ -37,6 +38,7 @@ module Cjules
p.on("--title TITLE", "Session title") { |v| title = v }
p.on("--file PATH", "Read prompt from file") { |v| file = v }
p.on("--template NAME", "Use a saved prompt template (see `cjules templates`)") { |v| template_name = v }
p.on("--var KEY=VALUE", "Define a template variable (can be used multiple times)") { |v| var_args << v }
p.on("--auto-pr", "Set automationMode=AUTO_CREATE_PR") { auto_pr = true }
p.on("--require-approval", "Require explicit plan approval") { require_approval = true }
p.on("--parallel N", "Create N concurrent sessions with the same prompt (account plan may limit N)") { |v| parallel = v.to_i }
Expand Down Expand Up @@ -67,7 +69,11 @@ module Cjules
end

prompt_arg = positional[0]?
prompt = Util::PromptInput.resolve(prompt_arg, file)

# Parse template variables if provided
vars = var_args.empty? ? nil : TemplateRenderer.parse_vars(var_args)

prompt = Util::PromptInput.resolve(prompt_arg, file, vars)
Comment thread
hahwul marked this conversation as resolved.

cfg = Config.load

Expand Down
12 changes: 10 additions & 2 deletions src/cjules/commands/retry.cr
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ module Cjules
with_failure_reason = false
output = "text"
positional = [] of String
var_args = [] of String

parser = OptionParser.new do |p|
p.banner = "Usage: cjules retry <ID> [options]"
p.on("--prompt TEXT", "Replace the original prompt") { |v| prompt_override = v }
p.on("--prompt-file PATH", "Replace the original prompt from file") { |v| prompt_file = v }
p.on("--template NAME", "Replace the original prompt with a saved template") { |v| template_name = v }
p.on("--var KEY=VALUE", "Define a template variable (can be used multiple times)") { |v| var_args << v }
p.on("--branch BRANCH", "Override starting branch") { |v| branch_override = v }
p.on("--note TEXT", "Append a note to the prompt") { |v| note = v }
p.on("--with-failure-reason", "Append the original session's failure reason as a note") { with_failure_reason = true }
Expand Down Expand Up @@ -63,11 +65,17 @@ module Cjules
client = Client.new(cfg)
original = API::Sessions.get(client, sid)

# Parse template variables if provided
vars = var_args.empty? ? nil : TemplateRenderer.parse_vars(var_args)

Comment thread
hahwul marked this conversation as resolved.
prompt =
if pf = prompt_file
File.read(pf).strip
raw = File.read(pf).strip
# Always render to support {{.File}}, {{.GitDiff}}, etc.
TemplateRenderer.render(raw, vars || TemplateRenderer::Variables.new)
elsif po = prompt_override
po
# Always render to support {{.File}}, {{.GitDiff}}, etc.
TemplateRenderer.render(po, vars || TemplateRenderer::Variables.new)
else
original.prompt || ""
end
Expand Down
6 changes: 6 additions & 0 deletions src/cjules/commands/templates.cr
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,14 @@ module Cjules
Templates are plain text or markdown files in:
#{dir}

Templates support dynamic variables:
{{.File "path"}} - Insert file content
{{.GitDiff}} - Insert git diff output
{{.Var "name"}} - Insert user-defined variable

Use a template with:
cjules new --template <name>
cjules new --template <name> --var key=value
USAGE
io.puts Help::GLOBAL_FLAGS
end
Expand Down
109 changes: 109 additions & 0 deletions src/cjules/template_renderer.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
module Cjules
# TemplateRenderer processes template strings with variable substitution.
# Supports:
# - {{.File "path"}} - Inserts file content
# - {{.GitDiff}} - Inserts git diff output
# - {{.Var "name"}} - Inserts user-defined variables
module TemplateRenderer
extend self

# Variable storage for user-defined variables
alias Variables = Hash(String, String)

# Render a template string with variable substitution
def render(template : String, vars : Variables = Variables.new) : String
result = template.dup

# Process {{.File "path"}} directives
result = process_file_directives(result)

# Process {{.GitDiff}} directives (compute diff once and reuse)
if result.includes?("{{.GitDiff}}")
diff = get_git_diff
result = result.gsub("{{.GitDiff}}", diff)
end

# Process {{.Var "name"}} directives
result = process_var_directives(result, vars)

result
end

# Parse --var arguments from CLI (format: key=value)
def parse_vars(var_args : Array(String)) : Variables
vars = Variables.new
var_args.each do |arg|
if m = arg.match(/^([^=]+)=(.*)$/)
key = m[1]
value = m[2]
vars[key] = value
else
STDERR.puts "warning: ignoring malformed --var argument: #{arg} (expected key=value)"
end
end
vars
end

private def process_file_directives(content : String) : String
# Match {{.File "path"}} or {{.File 'path'}}
content.gsub(/\{\{\.File\s+"([^"]+)"\}\}|\{\{\.File\s+'([^']+)'\}\}/) do
# Extract path from either double or single quotes
path = $~[1]? || $~[2]
read_file(path)
end
end

private def process_git_diff_directives(content : String) : String
content.gsub(/\{\{\.GitDiff\}\}/) do
get_git_diff
end
end
Comment thread
hahwul marked this conversation as resolved.

private def process_var_directives(content : String, vars : Variables) : String
# Match {{.Var "name"}} or {{.Var 'name'}}
content.gsub(/\{\{\.Var\s+"([^"]+)"\}\}|\{\{\.Var\s+'([^']+)'\}\}/) do
var_name = $~[1]? || $~[2]
vars[var_name]? || "[undefined variable: #{var_name}]"
end
end

private def read_file(path : String) : String
# Resolve relative paths from current directory
abs_path = File.expand_path(path)

unless File.exists?(abs_path)
return "[file not found: #{path}]"
end

unless File.file?(abs_path)
return "[not a file: #{path}]"
end

begin
File.read(abs_path)
rescue e : Exception
"[error reading file #{path}: #{e.message}]"
end
end

private def get_git_diff : String
begin
io = IO::Memory.new
status = Process.run("git", ["diff"], output: io, error: Process::Redirect::Close)

unless status.success?
return "[git diff failed]"
end

diff = io.to_s
if diff.empty?
return "[no git changes]"
end

diff
rescue e : Exception
"[error running git diff: #{e.message}]"
end
end
end
end
37 changes: 21 additions & 16 deletions src/cjules/util.cr
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "./models"
require "./template_renderer"

module Cjules
module Util
Expand Down Expand Up @@ -123,22 +124,26 @@ module Cjules
extend self

# Resolve prompt text from positional arg, file, or stdin.
def resolve(arg : String?, file : String? = nil) : String
if file
return File.read(file).strip
end
if arg == "-"
return STDIN.gets_to_end.strip
end
if arg && !arg.empty?
return arg.strip
end
if !STDIN.tty?
piped = STDIN.gets_to_end.strip
return piped unless piped.empty?
end
STDERR.puts "error: prompt is required (provide as argument, --file, or stdin)"
exit 2
# If vars is provided, applies template rendering with variable substitution.
def resolve(arg : String?, file : String? = nil, vars : TemplateRenderer::Variables? = nil) : String
raw_prompt = if file
File.read(file).strip
elsif arg == "-"
STDIN.gets_to_end.strip
elsif arg && !arg.empty?
arg.strip
elsif !STDIN.tty?
piped = STDIN.gets_to_end.strip
return piped unless piped.empty?
STDERR.puts "error: prompt is required (provide as argument, --file, or stdin)"
exit 2
else
STDERR.puts "error: prompt is required (provide as argument, --file, or stdin)"
exit 2
end

# Apply template rendering (always render to support {{.File}}, {{.GitDiff}}, etc.)
TemplateRenderer.render(raw_prompt, vars || TemplateRenderer::Variables.new)
end
end

Expand Down
Loading