diff --git a/README.md b/README.md index ac5fa24..a2ac6d7 100644 --- a/README.md +++ b/README.md @@ -62,11 +62,25 @@ echo "*.log" > ~/.config/scry/templates/default/.gitignore ```bash scry templates # List available templates -scry "ACME audit" # Creates directory with default template -scry "security-review" --template audit # Apply audit template instead +scry "ACME audit" # Prompts to pick a template +scry "security-review" --template audit # Apply audit template, skip the picker ``` -The `default` template is applied automatically when creating new directories. All files from the template directory are copied to the new scry directory. +When creating a new directory, scry shows an interactive template picker. If a +`default` template exists it is preselected, so pressing Enter accepts it. Pass +`--template NAME` to skip the picker and apply a specific template. All files +from the chosen template directory are copied into the new scry directory. + +## Harnesses + +The command launched after `cd` is the *harness*. Configure named harnesses and +switch between them per invocation: + +```bash +scry --harness codex "spike an idea" # Launch the "codex" harness +``` + +See [Configuration](#configuration) for defining harnesses. ## Cleanup @@ -87,15 +101,26 @@ Optional configuration via `~/.config/scry/config.json` or environment variables ```json { "path": "~/scries", - "agent": "claude" + "default_harness": "claude", + "harnesses": { + "claude": "claude --dangerously-skip-permissions", + "codex": "codex" + }, + "continue_flag": "--continue" } ``` +- `default_harness` — name of the harness launched when `--harness` is omitted. +- `harnesses` — named commands selectable with `--harness NAME`. +- `agent` — legacy single-command harness; still honored when no `harnesses` + map is configured. + Environment variables override config file settings: ```bash export SCRY_PATH=~/experiments # Custom directory location -export SCRY_AGENT=opencode # Different AI agent command +export SCRY_HARNESS=codex # Launch a configured harness by name +export SCRY_AGENT=opencode # Raw harness command (legacy override) ``` ## License diff --git a/shard.yml b/shard.yml index 7bf78e9..9947072 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: scry -version: 0.8.1 +version: 0.9.0 authors: - Stephen Dolan diff --git a/spec/config_spec.cr b/spec/config_spec.cr index dfb4bde..65246b1 100644 --- a/spec/config_spec.cr +++ b/spec/config_spec.cr @@ -24,4 +24,48 @@ describe Config do config.agent.should eq("codex") end end + + describe "#resolve_harness" do + it "resolves a named harness" do + json = %({ "harnesses": { "codex": "codex --yolo" } }) + config = Config.from_json(json) + config.resolve_harness("codex").should eq("codex --yolo") + end + + it "returns nil for an unknown named harness" do + config = Config.from_json(%({ "harnesses": { "codex": "codex" } })) + config.resolve_harness("nope").should be_nil + end + + it "falls back to the legacy agent when no harness is requested" do + prev_agent = ENV["SCRY_AGENT"]? + prev_harness = ENV["SCRY_HARNESS"]? + ENV.delete("SCRY_AGENT") + ENV.delete("SCRY_HARNESS") + + begin + config = Config.from_json(%({ "agent": "claude" })) + config.resolve_harness(nil).should eq("claude") + ensure + ENV["SCRY_AGENT"] = prev_agent if prev_agent + ENV["SCRY_HARNESS"] = prev_harness if prev_harness + end + end + + it "uses default_harness when no harness is requested" do + prev_agent = ENV["SCRY_AGENT"]? + prev_harness = ENV["SCRY_HARNESS"]? + ENV.delete("SCRY_AGENT") + ENV.delete("SCRY_HARNESS") + + begin + json = %({ "default_harness": "codex", "harnesses": { "codex": "codex" } }) + config = Config.from_json(json) + config.resolve_harness(nil).should eq("codex") + ensure + ENV["SCRY_AGENT"] = prev_agent if prev_agent + ENV["SCRY_HARNESS"] = prev_harness if prev_harness + end + end + end end diff --git a/spec/selector_spec.cr b/spec/selector_spec.cr index 0665e4e..fb056dd 100644 --- a/spec/selector_spec.cr +++ b/spec/selector_spec.cr @@ -59,32 +59,34 @@ describe ScrySelector do FileUtils.rm_rf(test_dir) end - it "does not go below 0 when pressing up at top" do + it "wraps to the last item when pressing up at top" do test_dir = File.tempname("scry-test") FileUtils.mkdir_p(test_dir) FileUtils.mkdir_p(File.join(test_dir, "2024-01-01-project-one")) - keyboard = MockKeyboard.new(["\e[A", "\e[A", "\e[A", "\e"]) + # One scry + "Create new" = 2 items. Up from index 0 wraps to index 1. + keyboard = MockKeyboard.new(["\e[A", "\e"]) output = IO::Memory.new selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false) selector.run - selector.cursor_pos.should eq(0) + selector.cursor_pos.should eq(1) FileUtils.rm_rf(test_dir) end - it "does not go past last item when pressing down at bottom" do + it "wraps to the first item when pressing down at bottom" do test_dir = File.tempname("scry-test") FileUtils.mkdir_p(test_dir) FileUtils.mkdir_p(File.join(test_dir, "2024-01-01-project-one")) - keyboard = MockKeyboard.new(["\e[B", "\e[B", "\e[B", "\e[B", "\e"]) + # From index 0: down -> 1 (last), down -> wraps to 0. + keyboard = MockKeyboard.new(["\e[B", "\e[B", "\e"]) output = IO::Memory.new selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false) selector.run - selector.cursor_pos.should eq(1) + selector.cursor_pos.should eq(0) FileUtils.rm_rf(test_dir) end @@ -379,6 +381,114 @@ describe ScrySelector do FileUtils.rm_rf(test_dir) end + + it "creates without a template when none are available" do + test_dir = File.tempname("scry-test") + FileUtils.mkdir_p(test_dir) + + keyboard = MockKeyboard.new(["x", "\r"]) + output = IO::Memory.new + selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false) + + result = selector.run + result.should_not be_nil + if r = result + r[:type].should eq(:mkdir) + r[:template].should be_nil + end + + FileUtils.rm_rf(test_dir) + end + end + + describe "template selection" do + it "picks the preselected default template on Enter" do + test_dir = File.tempname("scry-test") + FileUtils.mkdir_p(test_dir) + + # "demo" + Enter opens the picker, Enter again accepts the default. + keyboard = MockKeyboard.new(["d", "e", "m", "o", "\r", "\r"]) + output = IO::Memory.new + selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false, templates: ["default", "security"]) + + result = selector.run + result.should_not be_nil + if r = result + r[:type].should eq(:mkdir) + r[:template].should eq("default") + end + + FileUtils.rm_rf(test_dir) + end + + it "navigates to a different template" do + test_dir = File.tempname("scry-test") + FileUtils.mkdir_p(test_dir) + + keyboard = MockKeyboard.new(["d", "e", "m", "o", "\r", "\e[B", "\r"]) + output = IO::Memory.new + selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false, templates: ["default", "security"]) + + result = selector.run + if r = result + r[:template].should eq("security") + end + + FileUtils.rm_rf(test_dir) + end + + it "can choose no template" do + test_dir = File.tempname("scry-test") + FileUtils.mkdir_p(test_dir) + + # Options are default, security, (no template); move down twice. + keyboard = MockKeyboard.new(["x", "\r", "\e[B", "\e[B", "\r"]) + output = IO::Memory.new + selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false, templates: ["default", "security"]) + + result = selector.run + if r = result + r[:template].should be_nil + end + + FileUtils.rm_rf(test_dir) + end + + it "wraps from the top of the picker to (no template)" do + test_dir = File.tempname("scry-test") + FileUtils.mkdir_p(test_dir) + + # Picker opens on "default" (index 0); Up wraps to the trailing + # "(no template)" option. + keyboard = MockKeyboard.new(["d", "e", "m", "o", "\r", "\e[A", "\r"]) + output = IO::Memory.new + selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false, templates: ["default", "security"]) + + result = selector.run + if r = result + r[:template].should be_nil + end + + FileUtils.rm_rf(test_dir) + end + + it "skips the picker when a template is forced" do + test_dir = File.tempname("scry-test") + FileUtils.mkdir_p(test_dir) + + keyboard = MockKeyboard.new(["x", "\r"]) + output = IO::Memory.new + selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false, templates: ["default", "security"], force_template: "security") + + result = selector.run + result.should_not be_nil + if r = result + r[:type].should eq(:mkdir) + r[:template].should eq("security") + end + + FileUtils.rm_rf(test_dir) + end end describe "rendering" do @@ -455,12 +565,16 @@ describe ScrySelector do FileUtils.mkdir_p(File.join(test_dir, ".hidden")) FileUtils.mkdir_p(File.join(test_dir, "2024-01-01-visible")) - keyboard = MockKeyboard.new(["\e[B", "\e[B", "\e"]) + keyboard = MockKeyboard.new(["\e[B", "\e"]) output = IO::Memory.new selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false) selector.run + # Only the visible scry + "Create new" are navigable, so one Down lands + # on "Create new" (index 1) and the hidden dir never appears. selector.cursor_pos.should eq(1) + output.to_s.should contain("visible") + output.to_s.should_not contain(".hidden") FileUtils.rm_rf(test_dir) end diff --git a/src/scry.cr b/src/scry.cr index a56f764..80a1fe4 100644 --- a/src/scry.cr +++ b/src/scry.cr @@ -10,6 +10,8 @@ struct Config getter path : String = "~/scries" getter agent : String = "claude" getter continue_flag : String = "--continue" + getter default_harness : String? = nil + getter harnesses : Hash(String, String) = {} of String => String def self.load : Config config_path = File.expand_path("~/.config/scry/config.json", home: Path.home) @@ -42,6 +44,30 @@ struct Config ENV["SCRY_CONTINUE_FLAG"]? || @continue_flag end + # Resolve the command for a harness. An explicit name (from `--harness`) + # selects a named harness and returns nil when that name is unknown so the + # caller can report the error. Otherwise resolution falls through env + # overrides, the configured default harness, and finally the legacy `agent`. + def resolve_harness(name : String?) : String? + return @harnesses[name]? if name + + if env_name = ENV["SCRY_HARNESS"]? + if cmd = @harnesses[env_name]? + return cmd + end + end + + if override = ENV["SCRY_AGENT"]? + return override + end + + if default = @default_harness + return @harnesses[default]? || @agent + end + + @agent + end + private def expand_home_path(path : String) : String path.starts_with?("~") ? File.expand_path(path, home: Path.home) : path end @@ -365,11 +391,13 @@ module Scoring end class ScrySelector + NO_TEMPLATE_LABEL = "(no template)" + @search_term : String @cursor_pos : Int32 = 0 @scroll_offset : Int32 = 0 @input_buffer : String - @selected : NamedTuple(type: Symbol, path: String)? + @selected : NamedTuple(type: Symbol, path: String, template: String?)? @base_path : String @delete_status : String? @all_scries : Array(ScryDir)? @@ -377,8 +405,13 @@ class ScrySelector @output : IO @interactive : Bool @recalculate_cursor : Bool = true + @templates : Array(String) + @force_template : String? + @mode : Symbol = :browse + @pending_new_path : String? + @template_cursor : Int32 = 0 - def initialize(search_term = "", base_path : String = "", keyboard : KeyboardInput? = nil, output : IO? = nil, interactive : Bool = true) + def initialize(search_term = "", base_path : String = "", keyboard : KeyboardInput? = nil, output : IO? = nil, interactive : Bool = true, templates : Array(String) = [] of String, force_template : String? = nil) @search_term = sanitize_name(search_term) @input_buffer = @search_term @base_path = base_path.empty? ? File.expand_path("~/scries") : base_path @@ -386,6 +419,8 @@ class ScrySelector @keyboard = keyboard || StandardKeyboard.new @output = output || STDERR @interactive = interactive + @templates = templates + @force_template = force_template FileUtils.mkdir_p(@base_path) unless Dir.exists?(@base_path) end @@ -393,7 +428,7 @@ class ScrySelector getter cursor_pos : Int32 getter input_buffer : String - def run : NamedTuple(type: Symbol, path: String)? + def run : NamedTuple(type: Symbol, path: String, template: String?)? setup_terminal if @interactive @@ -514,6 +549,14 @@ class ScrySelector private def main_loop loop do + if @mode == :template + render_template_picker + key = @keyboard.read_key + handle_template_key(key) + break if @selected || key == "\x03" + next + end + scries = get_scries total_items = scries.size + 1 @@ -539,7 +582,7 @@ class ScrySelector when "\r", "\n" handle_enter(scries) when "\e[A", "\x10", "\x0B" - move_cursor_up + move_cursor_up(total_items) when "\e[B", "\x0E" move_cursor_down(total_items) when "\e[C", "\e[D" @@ -563,12 +606,12 @@ class ScrySelector end end - private def move_cursor_up - @cursor_pos = {@cursor_pos - 1, 0}.max + private def move_cursor_up(total_items : Int32) + @cursor_pos = (@cursor_pos - 1) % total_items end private def move_cursor_down(total_items : Int32) - @cursor_pos = {@cursor_pos + 1, total_items - 1}.min + @cursor_pos = (@cursor_pos + 1) % total_items end private def handle_backspace @@ -748,7 +791,7 @@ class ScrySelector end private def handle_selection(scry : ScryDir) - @selected = {type: :cd, path: scry.path} + @selected = {type: :cd, path: scry.path, template: nil} end private def handle_create_new @@ -757,8 +800,7 @@ class ScrySelector else date_prefix = Time.local.to_s("%Y-%m-%d") final_name = "#{date_prefix}-#{sanitize_name(@input_buffer)}" - full_path = File.join(@base_path, final_name) - @selected = {type: :mkdir, path: full_path} + begin_create(File.join(@base_path, final_name)) end end @@ -775,12 +817,79 @@ class ScrySelector return @selected = nil if entry.empty? final_name = "#{date_prefix}-#{sanitize_name(entry)}" - full_path = File.join(@base_path, final_name) - @selected = {type: :mkdir, path: full_path} + begin_create(File.join(@base_path, final_name)) ensure RawMode.enable end + # An explicit --template (force_template) or the absence of any templates + # finalizes creation immediately; otherwise we open the template picker. + private def begin_create(path : String) + if @force_template || @templates.empty? + @selected = {type: :mkdir, path: path, template: @force_template} + else + @pending_new_path = path + @template_cursor = default_template_index + @mode = :template + end + end + + private def default_template_index : Int32 + @templates.index("default") || 0 + end + + private def template_options : Array(String) + @templates + [NO_TEMPLATE_LABEL] + end + + private def handle_template_key(key : String) + case key + when "\r", "\n" + finalize_template_choice + when "\e[A", "\x10", "\x0B" + @template_cursor = (@template_cursor - 1) % template_options.size + when "\e[B", "\x0E" + @template_cursor = (@template_cursor + 1) % template_options.size + when "\x03" + @selected = nil + when "\e" + @mode = :browse + end + end + + private def finalize_template_choice + path = @pending_new_path + return unless path + + option = template_options[@template_cursor] + template = option == NO_TEMPLATE_LABEL ? nil : option + @selected = {type: :mkdir, path: path, template: template} + end + + private def render_template_picker + separator = "─" * (UI.width - 1) + + UI.puts "{h1}Scry" + UI.puts "{dim_text}#{separator}" + UI.puts "{highlight}New: {reset}#{File.basename(@pending_new_path || "")}" + UI.puts "{highlight}Choose a template:{reset}" + UI.puts "{dim_text}#{separator}" + + template_options.each_with_index do |option, idx| + is_selected = idx == @template_cursor + UI.print(is_selected ? "{highlight}> {reset_fg}" : " ") + UI.print "{start_selected}" if is_selected + UI.print option + UI.print "{end_selected}" if is_selected + UI.puts + end + + UI.puts "{dim_text}#{separator}" + UI.puts "{dim_text}Up/Down: Navigate Enter: Select ESC: Back{reset}" + + UI.flush(@output) + end + private def handle_delete(scry : ScryDir) size = get_directory_size(scry.path) files = count_files(scry.path) @@ -854,7 +963,10 @@ end def list_templates : Array(String) base = templates_base_path return [] of String unless Dir.exists?(base) - Dir.children(base).select { |name| Dir.exists?(File.join(base, name)) }.sort! + Dir.children(base) + .reject(&.starts_with?('.')) + .select { |name| Dir.exists?(File.join(base, name)) } + .sort! end def apply_template(template_dir : String, target_dir : String) : Nil @@ -885,10 +997,13 @@ def print_help(config : Config) Options: --template NAME Apply template when creating new scry + --harness NAME Launch a named harness instead of the default Examples: scry Browse all scries scry redis Jump to matching scry + scry --harness codex "spike" + Create/open a scry and launch the codex harness scry cleanup 30 Delete dirs older than 30 days scry cleanup 2024-01-01 Delete dirs before that date scry --template security "ACME SOC2" @@ -896,22 +1011,29 @@ def print_help(config : Config) Templates: Templates are directories in #{templates_base_path} - The "default" template is applied automatically (if it exists). + When creating a new scry, pick one interactively (the "default" template, + if present, is preselected). Pass --template NAME to skip the picker. Current config: - Path: #{config.effective_path} - Agent: #{config.effective_agent} - Continue flag: #{config.effective_continue_flag.empty? ? "(disabled)" : config.effective_continue_flag} + Path: #{config.effective_path} + Default harness: #{config.default_harness || "(agent) #{config.effective_agent}"} + Harnesses: #{config.harnesses.empty? ? "(none configured)" : config.harnesses.keys.join(", ")} + Continue flag: #{config.effective_continue_flag.empty? ? "(disabled)" : config.effective_continue_flag} Environment (overrides config file): SCRY_PATH Where scries are stored - SCRY_AGENT Command to run after cd + SCRY_HARNESS Name of a configured harness to launch + SCRY_AGENT Raw command to run after cd (legacy override) SCRY_CONTINUE_FLAG Flag to add when resuming (set empty to disable) Config file: ~/.config/scry/config.json { "path": "~/scries", - "agent": "claude", + "default_harness": "claude", + "harnesses": { + "claude": "claude --dangerously-skip-permissions", + "codex": "codex" + }, "continue_flag": "--continue" } @@ -1101,6 +1223,12 @@ end template_name = args.delete_at(idx) if idx < args.size end + harness_name : String? = nil + if idx = args.index("--harness") + args.delete_at(idx) + harness_name = args.delete_at(idx) if idx < args.size + end + args.shift if args.first? == "cd" if args.first? == "cleanup" @@ -1109,9 +1237,23 @@ end exit 0 end + # Resolve the harness up front so a bad --harness fails before the TUI opens. + agent_cmd = config.resolve_harness(harness_name) + if agent_cmd.nil? + STDERR.puts "Harness '#{harness_name}' not found." + names = config.harnesses.keys + STDERR.puts "Available harnesses: #{names.join(", ")}" unless names.empty? + exit 1 + end + search_term = args.join(" ") - selector = ScrySelector.new(search_term, base_path: config.effective_path) + selector = ScrySelector.new( + search_term, + base_path: config.effective_path, + templates: list_templates, + force_template: template_name, + ) unless STDIN.tty? && STDERR.tty? STDERR.puts "Error: scry requires an interactive terminal" @@ -1128,9 +1270,7 @@ end Process.run("git", ["init", "--quiet", path], error: Process::Redirect::Close) - tpl = template_name || ("default" if Dir.exists?(File.join(templates_base_path, "default"))) - - if tpl + if tpl = result[:template] tpl_dir = File.join(templates_base_path, tpl) unless Dir.exists?(tpl_dir) STDERR.puts "Template '#{tpl}' not found." @@ -1149,7 +1289,6 @@ end end escaped_path = path.gsub("'", "'\\''") - agent_cmd = config.effective_agent continue_flag = config.effective_continue_flag if result[:type] == :cd && !continue_flag.empty?